From 6d0f37ac2186c070d49806b1b05fd9b5805b09b5 Mon Sep 17 00:00:00 2001 From: YashaNoos <109368517+YashaNoos@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:38:26 +0100 Subject: [PATCH] Controller: layouts for the media timing review and duplicate card dialogs (#313) Co-authored-by: Claude Opus 5.5 --- changes/controller-field-grouping.md | 4 + changes/controller-media-timing-review.md | 5 + changes/controller-review-remap.md | 5 + config.example.jsonc | 283 ++++++++++ docs-site/anki-integration.md | 43 ++ docs-site/configuration.md | 25 +- docs-site/public/config.example.jsonc | 283 ++++++++++ docs-site/usage.md | 2 + src/config/config.test.ts | 114 ++++ src/config/definitions/defaults-core.ts | 5 + src/config/definitions/options-core.ts | 106 ++++ src/config/resolve/controller.ts | 70 +++ src/config/settings/registry.ts | 2 + src/core/services/ipc.test.ts | 5 + src/main/controller-config-update.test.ts | 57 ++ src/main/controller-config-update.ts | 35 ++ src/main/overlay-runtime.test.ts | 41 +- src/main/overlay-runtime.ts | 15 +- src/preload.ts | 5 + src/renderer/controller-profile-config.ts | 2 + .../handlers/controller-combo-capture.test.ts | 75 +++ .../handlers/controller-combo-capture.ts | 99 ++++ .../handlers/controller-screen-layout.test.ts | 157 ++++++ .../handlers/controller-screen-layout.ts | 213 +++++++ .../handlers/gamepad-controller.test.ts | 127 ++++- src/renderer/handlers/gamepad-controller.ts | 71 ++- .../media-timing-review-controller.test.ts | 307 +++++++++++ .../media-timing-review-controller.ts | 43 ++ src/renderer/index.html | 13 +- .../modals/controller-config-form.test.ts | 222 +++++++- src/renderer/modals/controller-config-form.ts | 188 ++++++- src/renderer/modals/controller-debug.test.ts | 11 + src/renderer/modals/controller-select.test.ts | 280 ++++++++++ src/renderer/modals/controller-select.ts | 217 +++++++- src/renderer/modals/kiku.test.ts | 175 ++++++ src/renderer/modals/kiku.ts | 115 ++++ .../modals/media-timing-review.test.ts | 22 + src/renderer/modals/media-timing-review.ts | 84 +++ src/renderer/renderer.ts | 88 ++- src/renderer/state.ts | 3 + src/renderer/style.css | 76 +++ src/renderer/utils/dom.ts | 10 + src/shared/controller-field-grouping.ts | 79 +++ src/shared/controller-review.test.ts | 150 +++++ src/shared/controller-review.ts | 518 ++++++++++++++++++ src/shared/controller-screens.ts | 40 ++ src/shared/ipc/contracts.ts | 1 + src/shared/ipc/validators.ts | 17 + src/types/config.ts | 6 + src/types/runtime.ts | 87 +++ 50 files changed, 4555 insertions(+), 46 deletions(-) create mode 100644 changes/controller-field-grouping.md create mode 100644 changes/controller-media-timing-review.md create mode 100644 changes/controller-review-remap.md create mode 100644 src/renderer/handlers/controller-combo-capture.test.ts create mode 100644 src/renderer/handlers/controller-combo-capture.ts create mode 100644 src/renderer/handlers/controller-screen-layout.test.ts create mode 100644 src/renderer/handlers/controller-screen-layout.ts create mode 100644 src/renderer/handlers/media-timing-review-controller.test.ts create mode 100644 src/renderer/handlers/media-timing-review-controller.ts create mode 100644 src/renderer/modals/kiku.test.ts create mode 100644 src/shared/controller-field-grouping.ts create mode 100644 src/shared/controller-review.test.ts create mode 100644 src/shared/controller-review.ts create mode 100644 src/shared/controller-screens.ts diff --git a/changes/controller-field-grouping.md b/changes/controller-field-grouping.md new file mode 100644 index 00000000..f6fe4f2d --- /dev/null +++ b/changes/controller-field-grouping.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`. diff --git a/changes/controller-media-timing-review.md b/changes/controller-media-timing-review.md new file mode 100644 index 00000000..b40278ef --- /dev/null +++ b/changes/controller-media-timing-review.md @@ -0,0 +1,5 @@ +type: added +area: overlay + +- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it. +- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom. diff --git a/changes/controller-review-remap.md b/changes/controller-review-remap.md new file mode 100644 index 00000000..8ae9e685 --- /dev/null +++ b/changes/controller-review-remap.md @@ -0,0 +1,5 @@ +type: added +area: overlay + +- The media timing review controller layout can now be changed in the new **Review media timing** group of `Alt+C`. Learn records a single button or a combo (hold one button, press another), any button can be a hold button, changes are saved per controller, rows warn about clashing inputs, and **Reset all** restores the default layout. The review's button hints follow your bindings. +- A newly opened `Alt+C` window shows and edits your saved controller's bindings until it sees the controller (browsers hide a pad from a new window until a button is pressed in it); before, it showed and edited the global defaults, and the status could stay stuck on "No controllers detected". diff --git a/config.example.jsonc b/config.example.jsonc index 2344fce4..9e8cc796 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -108,6 +108,7 @@ "stickDeadzone": 0.2, // Deadzone applied to controller stick axes. "triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog "triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode. + "faceButtonLabels": "xbox", // Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C. Values: xbox | nintendo "repeatDelayMs": 320, // Delay before repeating held controller actions. "repeatIntervalMs": 120, // Repeat interval for held controller actions. "buttonIndices": { @@ -189,6 +190,288 @@ "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + "reviewBindings": { + "confirm": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Use this timing. + "cancel": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Cancel. + "play": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Play selection. + "resetTiming": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Reset timing. + "showEarlier": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Earlier −2s. Repeats while held. + "showLater": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Later +2s. Repeats while held. + "framePrevious": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Previous screenshot frame. Repeats while held. + "frameNext": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Next screenshot frame. Repeats while held. + "frameReset": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Reset screenshot frame. + "startBack": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Start −0.1s. Repeats while held. + "startForward": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Start +0.1s. Repeats while held. + "previousLineAdd": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Add previous line. Repeats while held. + "previousLineRemove": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Remove previous line. Repeats while held. + "endBack": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: End −0.1s. Repeats while held. + "endForward": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: End +0.1s. Repeats while held. + "nextLineAdd": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Add next line. Repeats while held. + "nextLineRemove": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Remove next line. Repeats while held. + "useOriginal": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Use original timing (after cancel). + "keepEditing": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Keep editing (after cancel). + "skipMedia": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Without media (after cancel). + "discard": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + } // Review dialog: Discard card (after cancel). + }, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}. + "fieldGroupingBindings": { + "selectOriginal": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Original card. + "selectNew": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: New card. + "toggleDeleteDuplicate": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Delete duplicate on/off. + "continue": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Continue. + "cancel": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Cancel. + "confirmMerge": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Confirm merge (merge preview). + "back": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Back (merge preview). + "cancelMerge": { + "kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + }, // Duplicate card dialog: Cancel (merge preview). + "toggleView": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Compact / Full (merge preview). + "scrollUp": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Scroll up (merge preview). Repeats while held. + "scrollDown": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + } // Duplicate card dialog: Scroll down (merge preview). Repeats while held. + }, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}. "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 0b71127b..3f0d2ae7 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -160,6 +160,34 @@ The confirmed range is used as is, with no extra padding. Added lines go into th When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card. +#### With a controller + +With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press. + +`A`, `B`, `X` and `Y` mean the letters printed on your controller. If yours has A on the right and B at the bottom (Switch-style and many 8BitDo pads), set `controller.faceButtonLabels` to `"nintendo"`; PlayStation pads use the default (`A` is ✕, `B` ○, `X` □, `Y` △). + +Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held. + +This is the default layout: + +| Controller | Editing | After Cancel | +| ----------------------- | -------------------------------- | ------------------- | +| `A` | Use this timing | Use original timing | +| `B` | Cancel | Keep editing | +| `X` | Play selection | Without media | +| `Y` | Reset timing | Discard | +| D-pad left / right | Earlier −2s / Later +2s | | +| D-pad up / down | Previous / next screenshot frame | | +| `L1` + D-pad left/right | Start −0.1s / +0.1s | | +| `L1` + D-pad up/down | Add / remove a previous line | | +| `R1` + D-pad left/right | End −0.1s / +0.1s | | +| `R1` + D-pad up/down | Add / remove a next line | | +| `L1` + `R1` | Reset the screenshot frame | | + +To change it, open `Alt+C` and expand **Review media timing**. Click an action's pencil, then press a button, or hold one button and press another to make a combo; the first button becomes the one you hold. Any button can be a hold button. Changes are saved per controller, and **Reset all** brings back the default layout. + +Nudges, Earlier/Later, lines and frames repeat while held whichever buttons they are on. A row warns you when two actions on the same step share an input, or when an action uses a button alone that other actions hold. + ### Update behavior | Key | What it does | @@ -237,6 +265,21 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded | `Backspace` | Back from the merge preview | | `Esc` | Cancel and leave both cards unchanged | +### With a controller {#field-grouping-with-a-controller} + +With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (see `controller.faceButtonLabels`), and a button already held when the dialog opens waits for a fresh press. + +| Controller | Choosing the card | Merge preview | +| ------------------ | --------------------------------- | ----------------------- | +| D-pad left / right | Original card / New card | | +| D-pad up / down | | Scroll (repeats) | +| `A` | Continue | Confirm merge | +| `B` | Cancel | Back to the card choice | +| `X` | Turn "Delete duplicate" on or off | | +| `Y` | | Compact / Full | + +To cancel from the preview, press `B` twice, or bind the preview's **Cancel** row (unbound by default). Change any of it in the **Duplicate card (field grouping)** group of `Alt+C`, the same way as the review layout. + ## Config validation Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 2cc280d2..c5a3b115 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -292,19 +292,20 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w Gamepad input for the overlay, through the browser Gamepad API. Most actions only work while keyboard-only mode is on; the toggle for that mode and the secondary subtitle hold (`controller.bindings.holdSecondarySub`) work either way. Use the `Alt+C` modal to pick a controller and learn bindings, and `Alt+Shift+C` to see raw button and axis values. Default button actions are on [Keyboard shortcuts](/shortcuts). -| Key | Default | What it does | -| ---------------------------------- | -------- | ------------------------------------------------------------------------------------- | -| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off | -| `controller.smoothScroll` | `true` | Smooth popup scrolling | -| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed | -| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance | -| `controller.stickDeadzone` | `0.2` | Stick deadzone | -| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values | -| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` | -| `controller.repeatDelayMs` | `320` | Delay before a held button repeats | -| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons | +| Key | Default | What it does | +| ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `controller.enabled` | `false` | Enable controller support. The `Alt+C` and `Alt+Shift+C` modals stay closed while off | +| `controller.smoothScroll` | `true` | Smooth popup scrolling | +| `controller.scrollPixelsPerSecond` | `900` | Popup scroll speed | +| `controller.horizontalJumpPixels` | `160` | Popup page-jump distance | +| `controller.stickDeadzone` | `0.2` | Stick deadzone | +| `controller.triggerInputMode` | `"auto"` | `auto`, `digital`, or `analog`. Use `analog` if your L2/R2 report analog values | +| `controller.triggerDeadzone` | `0.5` | Trigger threshold for `auto` and `analog` | +| `controller.faceButtonLabels` | `"xbox"` | Letters printed on your face buttons. Use `nintendo` if A is on the right and B at the bottom (Switch-style and many 8BitDo pads); PlayStation pads use `xbox` | +| `controller.repeatDelayMs` | `320` | Delay before a held button repeats | +| `controller.repeatIntervalMs` | `120` | Repeat interval for held buttons | -Bindings 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. ## Anki integration diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 2344fce4..9e8cc796 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -108,6 +108,7 @@ "stickDeadzone": 0.2, // Deadzone applied to controller stick axes. "triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog "triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode. + "faceButtonLabels": "xbox", // Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C. Values: xbox | nintendo "repeatDelayMs": 320, // Delay before repeating held controller actions. "repeatIntervalMs": 120, // Repeat interval for held controller actions. "buttonIndices": { @@ -189,6 +190,288 @@ "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + "reviewBindings": { + "confirm": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Use this timing. + "cancel": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Cancel. + "play": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Play selection. + "resetTiming": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Reset timing. + "showEarlier": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Earlier −2s. Repeats while held. + "showLater": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Later +2s. Repeats while held. + "framePrevious": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Previous screenshot frame. Repeats while held. + "frameNext": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Next screenshot frame. Repeats while held. + "frameReset": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Reset screenshot frame. + "startBack": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Start −0.1s. Repeats while held. + "startForward": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Start +0.1s. Repeats while held. + "previousLineAdd": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Add previous line. Repeats while held. + "previousLineRemove": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 4 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Remove previous line. Repeats while held. + "endBack": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: End −0.1s. Repeats while held. + "endForward": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: End +0.1s. Repeats while held. + "nextLineAdd": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Add next line. Repeats while held. + "nextLineRemove": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": { + "kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "buttonIndex": 5 // Raw button index. + } // Optional input held while pressing; null for none. + }, // Review dialog: Remove next line. Repeats while held. + "useOriginal": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Use original timing (after cancel). + "keepEditing": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Keep editing (after cancel). + "skipMedia": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Review dialog: Without media (after cancel). + "discard": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + } // Review dialog: Discard card (after cancel). + }, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}. + "fieldGroupingBindings": { + "selectOriginal": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Original card. + "selectNew": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: New card. + "toggleDeleteDuplicate": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Delete duplicate on/off. + "continue": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Continue. + "cancel": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Cancel. + "confirmMerge": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Confirm merge (merge preview). + "back": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Back (merge preview). + "cancelMerge": { + "kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + }, // Duplicate card dialog: Cancel (merge preview). + "toggleView": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Compact / Full (merge preview). + "scrollUp": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + }, // Duplicate card dialog: Scroll up (merge preview). Repeats while held. + "scrollDown": { + "kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo + "press": { + "kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad + "direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive + }, // Input that triggers the action. + "hold": null // Optional input held while pressing; null for none. + } // Duplicate card dialog: Scroll down (merge preview). Repeats while held. + }, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}. "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. diff --git a/docs-site/usage.md b/docs-site/usage.md index 45875150..5c5efc2d 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -182,6 +182,8 @@ The secondary subtitle hold works with or without keyboard-only mode. Assign it **Previous Entry** and **Next Entry** move one word entry at a time in the open popup, and Mine Card then mines the selected entry. They send Yomitan's default `Alt+Up` / `Alt+Down` entry shortcuts, so they stop working if you rebind those in Yomitan's keyboard settings. If you assign them to the D-pad, click **Clear** on the **Popup Scroll (D-pad)** row too, or the D-pad will also scroll the popup. +The media timing review and duplicate card (field grouping) dialogs have their own controller layouts, which you can change in the **Review media timing** and **Duplicate card (field grouping)** groups of `Alt+C`; see [Review media timing](/anki-integration#with-a-controller) and [Field grouping](/anki-integration#field-grouping-with-a-controller). + On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support). ## Development profiles diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 1b1c383c..6108a90f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2190,3 +2190,117 @@ test('template generator shows built-in default keybindings in the keybindings a assert.deepEqual(parsed.keybindings, DEFAULT_KEYBINDINGS); }); + +test('parses controller face button labels and warns on unknown styles', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + '{ "controller": { "faceButtonLabels": "nintendo" } }', + 'utf-8', + ); + const validService = new ConfigService(validDir); + assert.equal(DEFAULT_CONFIG.controller.faceButtonLabels, 'xbox'); + assert.equal(validService.getConfig().controller.faceButtonLabels, 'nintendo'); + assert.deepEqual(validService.getWarnings(), []); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + '{ "controller": { "faceButtonLabels": "sega" } }', + 'utf-8', + ); + const invalidService = new ConfigService(invalidDir); + assert.equal(invalidService.getConfig().controller.faceButtonLabels, 'xbox'); + assert.ok( + invalidService.getWarnings().some((warning) => warning.path === 'controller.faceButtonLabels'), + ); +}); + +test('parses controller review bindings globally and per profile, and warns on bad entries', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + JSON.stringify({ + controller: { + reviewBindings: { + confirm: { press: { kind: 'button', buttonIndex: 9 } }, + discard: { kind: 'none' }, + play: { press: { kind: 'face', letter: 'q' } }, + notAnAction: { kind: 'none' }, + }, + profiles: { + 'pad-1': { + reviewBindings: { + startForward: { + press: { kind: 'dpad', direction: 'right' }, + hold: { kind: 'axis', axisIndex: 2, direction: 'positive' }, + }, + }, + }, + }, + }, + }), + 'utf-8', + ); + const service = new ConfigService(dir); + const controller = service.getConfig().controller; + + assert.deepEqual(controller.reviewBindings.confirm, { + kind: 'combo', + press: { kind: 'button', buttonIndex: 9 }, + hold: null, + }); + assert.deepEqual(controller.reviewBindings.discard, { kind: 'none' }); + // Invalid entries keep the default. + assert.deepEqual(controller.reviewBindings.play, DEFAULT_CONFIG.controller.reviewBindings.play); + // Profiles start from the resolved global layout, then apply their own overrides. + const profile = controller.profiles['pad-1']!.reviewBindings; + assert.deepEqual(profile.confirm, controller.reviewBindings.confirm); + assert.deepEqual(profile.startForward, { + kind: 'combo', + press: { kind: 'dpad', direction: 'right' }, + hold: { kind: 'axis', axisIndex: 2, direction: 'positive' }, + }); + const warnedPaths = service.getWarnings().map((warning) => warning.path); + assert.ok(warnedPaths.includes('controller.reviewBindings.play')); + assert.ok(warnedPaths.includes('controller.reviewBindings.notAnAction')); +}); + +test('parses duplicate card field grouping bindings globally and per profile', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + JSON.stringify({ + controller: { + fieldGroupingBindings: { + cancelMerge: { press: { kind: 'button', buttonIndex: 8 } }, + // A review action is not a field grouping action. + confirm: { kind: 'none' }, + }, + profiles: { + 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } }, + }, + }, + }), + 'utf-8', + ); + const service = new ConfigService(dir); + const controller = service.getConfig().controller; + + assert.deepEqual(controller.fieldGroupingBindings.cancelMerge, { + kind: 'combo', + press: { kind: 'button', buttonIndex: 8 }, + hold: null, + }); + assert.deepEqual( + controller.fieldGroupingBindings.continue, + DEFAULT_CONFIG.controller.fieldGroupingBindings.continue, + ); + const profile = controller.profiles['pad-1']!.fieldGroupingBindings; + assert.deepEqual(profile.cancelMerge, controller.fieldGroupingBindings.cancelMerge); + assert.deepEqual(profile.back, { kind: 'none' }); + // The review layout is untouched. + assert.deepEqual(controller.reviewBindings, DEFAULT_CONFIG.controller.reviewBindings); + const warnedPaths = service.getWarnings().map((warning) => warning.path); + assert.ok(warnedPaths.includes('controller.fieldGroupingBindings.confirm')); +}); diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 82c17690..291886e9 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -1,3 +1,5 @@ +import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping'; +import { getDefaultControllerReviewBindings } from '../../shared/controller-review'; import { ResolvedConfig } from '../../types/config'; export const CORE_DEFAULT_CONFIG: Pick< @@ -54,6 +56,7 @@ export const CORE_DEFAULT_CONFIG: Pick< stickDeadzone: 0.2, triggerInputMode: 'auto', triggerDeadzone: 0.5, + faceButtonLabels: 'xbox', repeatDelayMs: 320, repeatIntervalMs: 120, buttonIndices: { @@ -87,6 +90,8 @@ export const CORE_DEFAULT_CONFIG: Pick< rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, + reviewBindings: getDefaultControllerReviewBindings(), + fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(), profiles: {}, }, shortcuts: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 5499366b..beb7bdd0 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -1,4 +1,6 @@ +import { CONTROLLER_SCREENS, type ControllerScreenId } from '../../shared/controller-screens'; import { ResolvedConfig } from '../../types/config'; +import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types/runtime'; import { NOTIFICATION_TYPE_VALUES, OVERLAY_NOTIFICATION_POSITION_VALUES, @@ -6,6 +8,12 @@ import { } from '../../types/notification'; import { ConfigOptionRegistryEntry } from './shared'; +const CONTROLLER_SCREEN_INTROS: Record = { + review: 'Controller layout for the media timing review dialog, one entry per action.', + fieldGrouping: + 'Controller layout for the duplicate card (field grouping) dialog, one entry per action.', +}; + export function buildCoreConfigOptionRegistry( defaultConfig: ResolvedConfig, ): ConfigOptionRegistryEntry[] { @@ -224,6 +232,14 @@ export function buildCoreConfigOptionRegistry( description: 'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.', }, + { + path: 'controller.faceButtonLabels', + kind: 'enum', + enumValues: ['xbox', 'nintendo'], + defaultValue: defaultConfig.controller.faceButtonLabels, + description: + "Letters printed on your controller's face buttons: xbox (A at the bottom; also PlayStation) or nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads). Used by fixed controller layouts and button names in Alt+C.", + }, { path: 'controller.triggerDeadzone', kind: 'number', @@ -330,6 +346,96 @@ export function buildCoreConfigOptionRegistry( description: 'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.', }, + ...CONTROLLER_SCREENS.flatMap((screen) => { + const defaults = defaultConfig.controller[screen.configKey] as Record< + string, + ResolvedControllerReviewBinding + >; + const stepLabels = new Map(screen.steps.map((step, index) => [step.id, index])); + return [ + { + path: `controller.${screen.configKey}`, + kind: 'object' as const, + defaultValue: defaults, + description: `${CONTROLLER_SCREEN_INTROS[screen.id]} Set it with the ${screen.title} group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.`, + }, + ...screen.actions.flatMap((action): ConfigOptionRegistryEntry[] => { + const path = `controller.${screen.configKey}.${action.id}`; + const binding = defaults[action.id]!; + const press = binding.kind === 'combo' ? binding.press : undefined; + const hold = binding.kind === 'combo' ? binding.hold : undefined; + // Steps after the first are named, e.g. "(after Cancel)". + const stepNote = + (stepLabels.get(action.step) ?? 0) > 0 + ? ` (${screen.steps.find((step) => step.id === action.step)!.label.toLowerCase()})` + : ''; + const inputFields = ( + name: 'press' | 'hold', + input: ControllerReviewInput | null | undefined, + ): ConfigOptionRegistryEntry[] => [ + { + path: `${path}.${name}.kind`, + kind: 'enum', + enumValues: ['button', 'axis', 'face', 'dpad'], + defaultValue: input?.kind, + description: + 'Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction.', + }, + { + path: `${path}.${name}.buttonIndex`, + kind: 'number', + defaultValue: input?.kind === 'button' ? input.buttonIndex : undefined, + description: 'Raw button index.', + }, + { + path: `${path}.${name}.letter`, + kind: 'enum', + enumValues: ['a', 'b', 'x', 'y'], + defaultValue: input?.kind === 'face' ? input.letter : undefined, + description: 'Printed face letter, placed by controller.faceButtonLabels.', + }, + { + path: `${path}.${name}.direction`, + kind: 'enum', + enumValues: ['left', 'right', 'up', 'down', 'negative', 'positive'], + defaultValue: + input?.kind === 'dpad' || input?.kind === 'axis' ? input.direction : undefined, + description: 'D-pad direction, or axis direction for raw axis inputs.', + }, + ]; + return [ + { + path, + kind: 'object', + defaultValue: binding, + description: `${screen.describe}: ${action.label}${stepNote}.${action.repeat ? ' Repeats while held.' : ''}`, + }, + { + path: `${path}.kind`, + kind: 'enum', + enumValues: ['none', 'combo'], + defaultValue: binding.kind, + description: + 'none disables the action; combo presses "press" (while holding "hold", if set).', + }, + { + path: `${path}.press`, + kind: 'object', + defaultValue: press, + description: 'Input that triggers the action.', + }, + { + path: `${path}.hold`, + kind: 'object', + defaultValue: hold, + description: 'Optional input held while pressing; null for none.', + }, + ...inputFields('press', press), + ...inputFields('hold', hold), + ]; + }), + ]; + }), ...discreteBindings.flatMap((binding) => [ { path: `controller.bindings.${binding.id}`, diff --git a/src/config/resolve/controller.ts b/src/config/resolve/controller.ts index 0d7f91b4..3799445a 100644 --- a/src/config/resolve/controller.ts +++ b/src/config/resolve/controller.ts @@ -8,6 +8,13 @@ import type { ResolvedControllerBindingsConfig, ResolvedControllerDiscreteBinding, } from '../../types/runtime'; +import { + isControllerScreenAction, + parseControllerReviewBinding, + type ControllerScreenBindings, + type ControllerScreenDefinition, +} from '../../shared/controller-review'; +import { CONTROLLER_SCREENS } from '../../shared/controller-screens'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; @@ -268,6 +275,37 @@ function applyControllerBindings( } } +function applyControllerScreenBindings( + screen: ControllerScreenDefinition, + source: unknown, + target: ControllerScreenBindings, + pathPrefix: string, + warn: ResolveContext['warn'], +): void { + if (source === undefined) return; + if (!isObject(source)) { + warn(pathPrefix, source, target, `Expected an object keyed by ${screen.describe} action.`); + return; + } + for (const [action, value] of Object.entries(source)) { + if (!isControllerScreenAction(screen, action)) { + warn(`${pathPrefix}.${action}`, value, undefined, `Unknown ${screen.describe} action.`); + continue; + } + const parsed = parseControllerReviewBinding(value); + if (parsed) { + target[action] = parsed; + } else { + warn( + `${pathPrefix}.${action}`, + value, + target[action], + 'Expected {"kind": "none"} or {"press": input, "hold"?: input} with button, axis, face or dpad inputs.', + ); + } + } +} + export function applyControllerConfig(context: ResolveContext): void { const { src, resolved, warn } = context; if (!isObject(src.controller)) return; @@ -322,6 +360,18 @@ export function applyControllerConfig(context: ResolveContext): void { ); } + const faceButtonLabels = asString(src.controller.faceButtonLabels); + if (faceButtonLabels === 'xbox' || faceButtonLabels === 'nintendo') { + resolved.controller.faceButtonLabels = faceButtonLabels; + } else if (src.controller.faceButtonLabels !== undefined) { + warn( + 'controller.faceButtonLabels', + src.controller.faceButtonLabels, + resolved.controller.faceButtonLabels, + "Expected 'xbox' or 'nintendo'.", + ); + } + const boundedNumberKeys = [ 'scrollPixelsPerSecond', 'horizontalJumpPixels', @@ -370,6 +420,15 @@ export function applyControllerConfig(context: ResolveContext): void { 'controller.bindings', warn, ); + for (const screen of CONTROLLER_SCREENS) { + applyControllerScreenBindings( + screen, + src.controller[screen.configKey], + resolved.controller[screen.configKey], + `controller.${screen.configKey}`, + warn, + ); + } if (isObject(src.controller.profiles)) { for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) { @@ -406,6 +465,8 @@ export function applyControllerConfig(context: ResolveContext): void { label: label ?? profileId, buttonIndices: structuredClone(resolved.controller.buttonIndices), bindings: structuredClone(resolved.controller.bindings), + reviewBindings: structuredClone(resolved.controller.reviewBindings), + fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings), }; applyControllerButtonIndices( rawProfile.buttonIndices, @@ -420,6 +481,15 @@ export function applyControllerConfig(context: ResolveContext): void { `controller.profiles.${profileId}.bindings`, warn, ); + for (const screen of CONTROLLER_SCREENS) { + applyControllerScreenBindings( + screen, + rawProfile[screen.configKey], + profile[screen.configKey], + `controller.profiles.${profileId}.${screen.configKey}`, + warn, + ); + } resolved.controller.profiles[profileId] = profile; } } diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 636d05a0..bda63b98 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -58,6 +58,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'ankiConnect.isLapis.sentenceCardAudioField', 'ankiConnect.fields.translation', 'controller.bindings', + 'controller.reviewBindings', + 'controller.fieldGroupingBindings', 'controller.preferredGamepadId', 'controller.preferredGamepadLabel', 'controller.profiles', diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index aa934980..ef4cda73 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import type { SessionActionDispatchRequest, SubtitleSidebarSnapshot } from '../../types'; +import { getDefaultControllerReviewBindings } from '../../shared/controller-review'; +import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping'; interface FakeIpcRegistrar { on: Map void>; @@ -45,6 +47,7 @@ function createControllerConfigFixture() { stickDeadzone: 0.2, triggerInputMode: 'auto' as const, triggerDeadzone: 0.5, + faceButtonLabels: 'xbox' as const, repeatDelayMs: 220, repeatIntervalMs: 80, buttonIndices: { @@ -82,6 +85,8 @@ function createControllerConfigFixture() { rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const }, rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const }, }, + reviewBindings: getDefaultControllerReviewBindings(), + fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(), profiles: {}, }; } diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts index b13b02d5..46cf78ed 100644 --- a/src/main/controller-config-update.test.ts +++ b/src/main/controller-config-update.test.ts @@ -129,3 +129,60 @@ test('applyControllerConfigUpdate ignores reserved profile ids', () => { assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false); assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1'); }); + +test('applyControllerConfigUpdate merges review bindings per action inside a profile', () => { + const next = applyControllerConfigUpdate( + { + profiles: { + 'pad-1': { + bindings: { toggleLookup: { kind: 'button', buttonIndex: 0 } }, + reviewBindings: { + confirm: { press: { kind: 'button', buttonIndex: 1 } }, + cancel: { kind: 'none' }, + }, + }, + }, + }, + { + profiles: { + 'pad-1': { + reviewBindings: { + cancel: { + kind: 'combo', + press: { kind: 'button', buttonIndex: 0 }, + hold: { kind: 'button', buttonIndex: 4 }, + }, + }, + }, + }, + }, + ); + + assert.deepEqual(next.profiles?.['pad-1'], { + bindings: { toggleLookup: { kind: 'button', buttonIndex: 0 } }, + reviewBindings: { + confirm: { press: { kind: 'button', buttonIndex: 1 } }, + cancel: { + kind: 'combo', + press: { kind: 'button', buttonIndex: 0 }, + hold: { kind: 'button', buttonIndex: 4 }, + }, + }, + }); +}); + +test('applyControllerConfigUpdate merges field grouping bindings next to review bindings', () => { + const next = applyControllerConfigUpdate( + { + reviewBindings: { confirm: { kind: 'none' } }, + fieldGroupingBindings: { back: { kind: 'none' } }, + }, + { fieldGroupingBindings: { cancelMerge: { press: { kind: 'face', letter: 'b' } } } }, + ); + + assert.deepEqual(next.reviewBindings, { confirm: { kind: 'none' } }); + assert.deepEqual(next.fieldGroupingBindings, { + back: { kind: 'none' }, + cancelMerge: { press: { kind: 'face', letter: 'b' } }, + }); +}); diff --git a/src/main/controller-config-update.ts b/src/main/controller-config-update.ts index 69ff2c7d..a895f7b3 100644 --- a/src/main/controller-config-update.ts +++ b/src/main/controller-config-update.ts @@ -2,6 +2,9 @@ import type { ControllerConfigUpdate, RawConfig } from '../types'; type RawControllerConfig = NonNullable; type RawControllerBindings = NonNullable; +type RawControllerScreenBindings = Record; +/** Per-dialog controller layouts stored as per-action patches. */ +const SCREEN_BINDING_KEYS = ['reviewBindings', 'fieldGroupingBindings'] as const; type RawControllerButtonIndices = NonNullable; type RawControllerProfiles = NonNullable; type RawControllerProfile = NonNullable; @@ -27,6 +30,34 @@ function mergeBindingPatch( return nextBindings; } +function mergeScreenBindingPatch( + currentBindings: RawControllerScreenBindings | undefined, + updateBindings: RawControllerScreenBindings | undefined, +): RawControllerScreenBindings | undefined { + if (!currentBindings && !updateBindings) return undefined; + const nextBindings: RawControllerScreenBindings = { ...(currentBindings ?? {}) }; + for (const [action, binding] of Object.entries(updateBindings ?? {})) { + if (binding === undefined) continue; + nextBindings[action] = structuredClone(binding); + } + return nextBindings; +} + +/** Merges every per-dialog layout patch from `update` onto `current` into `target`. */ +function mergeScreenBindingPatches( + current: Partial> | undefined, + update: Partial>, + target: Partial>, +): void { + for (const key of SCREEN_BINDING_KEYS) { + const merged = mergeScreenBindingPatch( + current?.[key] as RawControllerScreenBindings | undefined, + update[key] as RawControllerScreenBindings | undefined, + ); + if (merged) target[key] = merged; + } +} + function mergeButtonIndexPatch( currentButtonIndices: RawControllerButtonIndices | undefined, updateButtonIndices: RawControllerButtonIndices | undefined, @@ -60,6 +91,8 @@ function mergeControllerProfile( nextProfile.bindings = bindings; } + mergeScreenBindingPatches(currentProfile, updateProfile, nextProfile); + return nextProfile; } @@ -85,6 +118,8 @@ export function applyControllerConfigUpdate( nextController.bindings = bindings; } + mergeScreenBindingPatches(currentController, update, nextController); + if (currentController?.profiles || update.profiles) { const nextProfiles: RawControllerProfiles = {}; for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array< diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 00a21768..179c7e1b 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -8,7 +8,7 @@ import { createHarness, type FakeOverlayWindowInit, } from './overlay-runtime-test-harness'; -import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import { IPC_CHANNELS, type OverlayHostedModal } from '../shared/ipc/contracts'; const SUBSYNC_PAYLOAD = { ffsubsyncAvailable: true, @@ -749,3 +749,42 @@ test('Linux keeps the dedicated modal window unmapped until the renderer opens t assert.equal(modal.isVisible(), true); assert.equal(modal.ignoreMouseEvents, false); }); + +test('modal window path suspends the main overlay controller until the last modal closes', () => { + const main = new FakeOverlayWindow({ visible: true }); + const h = createHarness({ main, modal: new FakeOverlayWindow() }); + const suspendMessages = () => + main.sent.filter(([channel]) => channel === IPC_CHANNELS.event.controllerSuspendedChanged); + + h.runtime.sendToActiveOverlayWindow( + 'media-timing-review:open', + { reviewId: 'r-1' }, + { restoreOnModalClose: 'media-timing-review', preferModalWindow: true }, + ); + h.runtime.notifyOverlayModalOpened('media-timing-review'); + assert.deepEqual(suspendMessages(), [[IPC_CHANNELS.event.controllerSuspendedChanged, true]]); + + h.runtime.handleOverlayModalClosed('media-timing-review'); + assert.deepEqual(suspendMessages(), [ + [IPC_CHANNELS.event.controllerSuspendedChanged, true], + [IPC_CHANNELS.event.controllerSuspendedChanged, false], + ]); +}); + +test('a modal hosted in the main overlay keeps that overlay controller active', () => { + const main = new FakeOverlayWindow({ visible: true }); + const h = createHarness({ main, createModal: () => new FakeOverlayWindow() }); + + h.runtime.sendToActiveOverlayWindow( + 'media-timing-review:open', + { reviewId: 'r-1' }, + { restoreOnModalClose: 'media-timing-review' }, + ); + h.runtime.notifyOverlayModalOpened('media-timing-review'); + h.runtime.handleOverlayModalClosed('media-timing-review'); + + assert.equal( + main.sent.some(([channel]) => channel === IPC_CHANNELS.event.controllerSuspendedChanged), + false, + ); +}); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 5e78bc01..2b7e3b6e 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,5 +1,5 @@ import type { BrowserWindow } from 'electron'; -import type { OverlayHostedModal } from '../shared/ipc/contracts'; +import { IPC_CHANNELS, type OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement'; import { applyOverlayClickThrough } from '../core/services/overlay-click-through'; @@ -83,6 +83,7 @@ export function createOverlayModalRuntimeService( let modalActive = false; let mainWindowMousePassthroughForcedByModal = false; let mainWindowHiddenByModal = false; + let mainWindowControllerSuspended = false; let modalWindowPrimedForImmediateShow = false; let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; @@ -411,6 +412,16 @@ export function createOverlayModalRuntimeService( mainWindowHiddenByModal = false; }; + // The hidden main overlay keeps polling the gamepad (background throttling is off), so + // tell it to stand down while the modal window owns the controller. + const setMainWindowControllerSuspended = (suspended: boolean): void => { + if (mainWindowControllerSuspended === suspended) return; + mainWindowControllerSuspended = suspended; + const mainWindow = deps.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(IPC_CHANNELS.event.controllerSuspendedChanged, suspended); + }; + const scheduleModalWindowReveal = (window: BrowserWindow): void => { pendingModalWindowReveal = window; if (pendingModalWindowRevealTimeout !== null) { @@ -560,6 +571,7 @@ export function createOverlayModalRuntimeService( } mainWindowMousePassthroughForcedByModal = false; setMainWindowVisibilityForModal(false); + setMainWindowControllerSuspended(false); try { options.onFinalModalClosed?.(); } catch { @@ -591,6 +603,7 @@ export function createOverlayModalRuntimeService( const handOffMainWindowToModal = (): void => { setMainWindowMousePassthroughForModal(true); setMainWindowVisibilityForModal(true); + setMainWindowControllerSuspended(true); }; if (targetIsModalWindow && deferModalRevealUntilOpened) { diff --git a/src/preload.ts b/src/preload.ts index 38473fe5..8ead4265 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -186,6 +186,10 @@ const onSessionNumericSelectionStartEvent = IPC_CHANNELS.event.sessionNumericSelectionStart, (payload) => payload as SessionNumericSelectionStartPayload, ); +const onControllerSuspendedChangedEvent = createQueuedIpcListenerWithPayload( + IPC_CHANNELS.event.controllerSuspendedChanged, + (payload) => payload === true, +); const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener( IPC_CHANNELS.event.keyboardModeToggleRequested, ); @@ -491,6 +495,7 @@ const electronAPI: ElectronAPI = { onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, + onControllerSuspendedChanged: onControllerSuspendedChangedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, getPlaylistBrowserSnapshot: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot), diff --git a/src/renderer/controller-profile-config.ts b/src/renderer/controller-profile-config.ts index e07717cd..0b7d4e92 100644 --- a/src/renderer/controller-profile-config.ts +++ b/src/renderer/controller-profile-config.ts @@ -18,5 +18,7 @@ export function resolveControllerConfigForGamepad( ...config, buttonIndices: profile.buttonIndices, bindings: profile.bindings, + reviewBindings: profile.reviewBindings ?? config.reviewBindings, + fieldGroupingBindings: profile.fieldGroupingBindings ?? config.fieldGroupingBindings, }; } diff --git a/src/renderer/handlers/controller-combo-capture.test.ts b/src/renderer/handlers/controller-combo-capture.test.ts new file mode 100644 index 00000000..e3c768b3 --- /dev/null +++ b/src/renderer/handlers/controller-combo-capture.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createControllerComboCapture } from './controller-combo-capture.js'; + +const PRESSED = { value: 1, pressed: true }; +const RELEASED = { value: 0, pressed: false }; + +function snapshot(pressed: number[] = [], axes: number[] = [0, 0, 0, 0]) { + return { + axes, + buttons: Array.from({ length: 17 }, (_, index) => + pressed.includes(index) ? PRESSED : RELEASED, + ), + }; +} + +function createCapture() { + return createControllerComboCapture({ triggerDeadzone: 0.5, stickDeadzone: 0.2 }); +} + +test('combo capture records a single button once it is released', () => { + const capture = createCapture(); + capture.arm(snapshot()); + + assert.deepEqual(capture.poll(snapshot()), { status: 'waiting' }); + assert.deepEqual(capture.poll(snapshot([1])), { + status: 'holding', + hold: { kind: 'button', buttonIndex: 1 }, + }); + assert.deepEqual(capture.poll(snapshot()), { + status: 'done', + binding: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null }, + }); +}); + +test('combo capture records hold first + press second', () => { + const capture = createCapture(); + capture.arm(snapshot()); + + capture.poll(snapshot([4])); + assert.deepEqual(capture.poll(snapshot([4, 15])), { + status: 'done', + binding: { + kind: 'combo', + press: { kind: 'button', buttonIndex: 15 }, + hold: { kind: 'button', buttonIndex: 4 }, + }, + }); +}); + +test('combo capture ignores buttons held when Learn starts until they are released', () => { + const capture = createCapture(); + capture.arm(snapshot([0])); + + assert.deepEqual(capture.poll(snapshot([0])), { status: 'waiting' }); + capture.poll(snapshot()); + assert.deepEqual(capture.poll(snapshot([0])), { + status: 'holding', + hold: { kind: 'button', buttonIndex: 0 }, + }); +}); + +test('combo capture never records a trigger axis resting position', () => { + const capture = createCapture(); + // Some pads rest trigger axes at -1. + capture.arm(snapshot([], [0, 0, -1, 0])); + + capture.poll(snapshot([], [0, 0, -0.2, 0])); + assert.deepEqual(capture.poll(snapshot([], [0, 0, -1, 0])), { status: 'waiting' }); + assert.deepEqual(capture.poll(snapshot([], [0, 0, 0.9, 0])), { + status: 'holding', + hold: { kind: 'axis', axisIndex: 2, direction: 'positive' }, + }); +}); diff --git a/src/renderer/handlers/controller-combo-capture.ts b/src/renderer/handlers/controller-combo-capture.ts new file mode 100644 index 00000000..69ee5f10 --- /dev/null +++ b/src/renderer/handlers/controller-combo-capture.ts @@ -0,0 +1,99 @@ +import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types'; + +type ControllerButtonState = { + value: number; + pressed?: boolean; + touched?: boolean; +}; + +type ControllerSnapshot = { + axes: readonly number[]; + buttons: readonly ControllerButtonState[]; +}; + +export type ControllerComboCaptureState = + | { status: 'waiting' } + | { status: 'holding'; hold: ControllerReviewInput } + | { status: 'done'; binding: ResolvedControllerReviewBinding }; + +type RawInput = Extract; + +function inputKey(input: RawInput): 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, + * 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 + * -1, and brushing it must not record that resting position as a press. + */ +export function createControllerComboCapture(options: { + triggerDeadzone: number; + stickDeadzone: number; +}) { + const blocked = new Set(); + const restingAxes = new Set(); + let first: RawInput | null = null; + + function activeInputs(snapshot: ControllerSnapshot): RawInput[] { + const inputs: RawInput[] = []; + snapshot.buttons.forEach((button, buttonIndex) => { + if (button && (Boolean(button.pressed) || button.value >= options.triggerDeadzone)) { + inputs.push({ kind: 'button', buttonIndex }); + } + }); + const threshold = Math.max(options.stickDeadzone, 0.55); + snapshot.axes.forEach((value, axisIndex) => { + if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) < threshold) { + return; + } + inputs.push({ kind: 'axis', axisIndex, direction: value > 0 ? 'positive' : 'negative' }); + }); + return inputs; + } + + function arm(snapshot: ControllerSnapshot): void { + first = null; + blocked.clear(); + restingAxes.clear(); + for (const input of activeInputs(snapshot)) { + (input.kind === 'axis' ? restingAxes : blocked).add(inputKey(input)); + } + } + + function poll(snapshot: ControllerSnapshot): ControllerComboCaptureState { + const active = activeInputs(snapshot).filter((input) => !restingAxes.has(inputKey(input))); + const activeKeys = new Set(active.map(inputKey)); + for (const key of [...blocked]) { + if (!activeKeys.has(key)) blocked.delete(key); + } + const fresh = active.filter((input) => !blocked.has(inputKey(input))); + + if (first === null) { + const next = fresh[0]; + if (!next) return { status: 'waiting' }; + first = next; + return { status: 'holding', hold: first }; + } + + const firstKey = inputKey(first); + if (!activeKeys.has(firstKey)) { + const press = first; + first = null; + return { status: 'done', binding: { kind: 'combo', press, hold: null } }; + } + const second = fresh.find((input) => inputKey(input) !== firstKey); + if (second) { + const hold = first; + first = null; + return { status: 'done', binding: { kind: 'combo', press: second, hold } }; + } + return { status: 'holding', hold: first }; + } + + return { arm, poll }; +} diff --git a/src/renderer/handlers/controller-screen-layout.test.ts b/src/renderer/handlers/controller-screen-layout.test.ts new file mode 100644 index 00000000..03693b46 --- /dev/null +++ b/src/renderer/handlers/controller-screen-layout.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core'; +import { + CONTROLLER_FIELD_GROUPING_ACTIONS, + CONTROLLER_FIELD_GROUPING_SCREEN, + type ControllerFieldGroupingStep, +} from '../../shared/controller-field-grouping'; +import { findControllerScreenBindingIssues } from '../../shared/controller-review'; +import type { ControllerFieldGroupingAction, ResolvedControllerConfig } from '../../types'; +import { + createControllerComboScreenLayout, + formatControllerScreenHints, + getControllerReviewInputContext, +} from './controller-screen-layout.js'; +import type { GamepadLike } from './gamepad-controller.js'; + +const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true }; +const { repeatDelayMs, repeatIntervalMs } = CONFIG; + +// Standard-layout indices; faces follow config.buttonIndices (Xbox positions by default). +const BUTTON = { A: 0, B: 1, X: 2, Y: 3, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 }; +type ButtonName = keyof typeof BUTTON; + +function pad(pressed: ButtonName[] = []): GamepadLike { + const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false })); + for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true }; + return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes: [0, 0, 0, 0], buttons }; +} + +/** The field grouping layout driving a fake dialog that follows the default flow. */ +function createFieldGroupingLayout(config = CONFIG) { + const actions: ControllerFieldGroupingAction[] = []; + const state: { step: ControllerFieldGroupingStep } = { step: 'select' }; + const layout = createControllerComboScreenLayout({ + screen: CONTROLLER_FIELD_GROUPING_SCREEN, + getBindings: (resolved) => resolved.fieldGroupingBindings, + getStep: () => state.step, + dispatch: (action) => { + actions.push(action); + if (action === 'continue') state.step = 'preview'; + if (action === 'back') state.step = 'select'; + }, + }); + layout.activate(pad(), config, 0, { latchHeld: true }); + return { layout, actions, state }; +} + +test('the default duplicate card layout binds every action but preview Cancel, without conflicts', () => { + for (const action of CONTROLLER_FIELD_GROUPING_ACTIONS) { + assert.equal( + CONFIG.fieldGroupingBindings[action.id]?.kind, + action.id === 'cancelMerge' ? 'none' : 'combo', + action.id, + ); + } + for (const mapping of ['standard', ''] as const) { + for (const faceButtonLabels of ['xbox', 'nintendo'] as const) { + const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping); + assert.deepEqual( + findControllerScreenBindingIssues( + CONTROLLER_FIELD_GROUPING_SCREEN, + CONFIG.fieldGroupingBindings, + context, + ), + {}, + ); + } + } +}); + +test('duplicate card hints read like the agreed layout', () => { + const hints = formatControllerScreenHints( + CONTROLLER_FIELD_GROUPING_SCREEN, + CONFIG.fieldGroupingBindings, + CONFIG, + 'standard', + ); + assert.deepEqual(hints, { + selectOriginal: '◀', + selectNew: '▶', + toggleDeleteDuplicate: 'X', + continue: 'A', + cancel: 'B', + confirmMerge: 'A', + back: 'B', + cancelMerge: null, + toggleView: 'Y', + scrollUp: '▲', + scrollDown: '▼', + }); +}); + +test('the D-pad picks the card and X toggles delete on the first step', () => { + const { layout, actions } = createFieldGroupingLayout(); + + layout.poll(pad(['RIGHT']), CONFIG, 10); + layout.poll(pad(['RIGHT']), CONFIG, 2_000); + layout.poll(pad(), CONFIG, 2_010); + layout.poll(pad(['LEFT']), CONFIG, 2_020); + layout.poll(pad(['X']), CONFIG, 2_030); + // Up/down scroll only on the preview. + layout.poll(pad(['DOWN']), CONFIG, 2_040); + + assert.deepEqual(actions, ['selectNew', 'selectOriginal', 'toggleDeleteDuplicate']); +}); + +test('B on the preview goes back; holding it does not also cancel', () => { + const { layout, actions, state } = createFieldGroupingLayout(); + + layout.poll(pad(['A']), CONFIG, 10); + assert.equal(state.step, 'preview'); + // A still held after the step change does not confirm the merge. + layout.poll(pad(['A']), CONFIG, 20); + layout.poll(pad(), CONFIG, 30); + layout.poll(pad(['B']), CONFIG, 40); + assert.equal(state.step, 'select'); + // B held across the step change is not a fresh press on the first step. + layout.poll(pad(['B']), CONFIG, 1_000); + layout.poll(pad(), CONFIG, 1_010); + layout.poll(pad(['B']), CONFIG, 1_020); + + assert.deepEqual(actions, ['continue', 'back', 'cancel']); +}); + +test('preview scrolling repeats while the D-pad is held', () => { + const { layout, actions, state } = createFieldGroupingLayout(); + state.step = 'preview'; + + layout.poll(pad(['DOWN']), CONFIG, 0); + layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs - 1); + layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs); + layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs + repeatIntervalMs); + layout.poll(pad(), CONFIG, repeatDelayMs + repeatIntervalMs + 1); + layout.poll(pad(['Y']), CONFIG, 5_000); + layout.poll(pad(['Y']), CONFIG, 6_000); + + assert.deepEqual(actions, ['scrollDown', 'scrollDown', 'scrollDown', 'toggleView']); +}); + +test('remapped duplicate card bindings replace the defaults', () => { + const config: ResolvedControllerConfig = { + ...CONFIG, + fieldGroupingBindings: { + ...CONFIG.fieldGroupingBindings, + back: { kind: 'none' }, + cancelMerge: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null }, + }, + }; + const { layout, actions, state } = createFieldGroupingLayout(config); + state.step = 'preview'; + + layout.poll(pad(['B']), config, 10); + + assert.deepEqual(actions, ['cancelMerge']); +}); diff --git a/src/renderer/handlers/controller-screen-layout.ts b/src/renderer/handlers/controller-screen-layout.ts new file mode 100644 index 00000000..e30d7e1d --- /dev/null +++ b/src/renderer/handlers/controller-screen-layout.ts @@ -0,0 +1,213 @@ +import { + compileControllerScreenBindings, + formatControllerReviewBinding, + type CompiledControllerScreenBinding, + type ControllerReviewInputContext, + type ControllerReviewInputSource, + type ControllerScreenBindings, + type ControllerScreenDefinition, +} from '../../shared/controller-review'; +import type { ResolvedControllerConfig } from '../../types'; +import { + createHoldState, + resolveDiscreteBindingPressed, + resolveDpadHorizontalValue, + resolveDpadVerticalValue, + shouldFireHeldAction, + type ControllerScreenLayout, + type GamepadLike, + type HoldState, +} from './gamepad-controller.js'; + +export function getControllerReviewInputContext( + config: ResolvedControllerConfig, + mapping: string | null | undefined, +): ControllerReviewInputContext { + return { + mapping, + faceButtonLabels: config.faceButtonLabels, + buttonIndices: config.buttonIndices, + }; +} + +/** Hint text per action of a screen, e.g. `A` or `L1 + ▶`; null when unbound. */ +export function formatControllerScreenHints( + screen: ControllerScreenDefinition, + bindings: Partial>, + config: ResolvedControllerConfig, + mapping: string | null | undefined, +): Record { + const context = getControllerReviewInputContext(config, mapping); + const hints = {} as Record; + for (const action of screen.actions) { + const binding = bindings[action.id]; + hints[action.id] = + binding && binding.kind === 'combo' ? formatControllerReviewBinding(binding, context) : null; + } + return hints; +} + +function isSourcePressed( + source: ControllerReviewInputSource, + gamepad: GamepadLike, + config: ResolvedControllerConfig, +): boolean { + if (source.kind === 'dpad') { + if (source.direction === 'left' || source.direction === 'right') { + const value = resolveDpadHorizontalValue(gamepad, config.triggerDeadzone); + return source.direction === 'left' ? value < 0 : value > 0; + } + const value = resolveDpadVerticalValue(gamepad, config.triggerDeadzone); + return source.direction === 'up' ? value < 0 : value > 0; + } + return resolveDiscreteBindingPressed(gamepad, source, config); +} + +type PressChannel = { + action: A | null; + repeat: boolean; + /** Hold buttons that were down when this press started. */ + modifiers: string; + /** Set when the hold buttons changed mid-press; the press must be released first. */ + latched: boolean; + hold: HoldState; +}; + +type ScreenFrame = { + compiled: CompiledControllerScreenBinding[]; + pressed: Set; + holdKeys: Set; +}; + +/** + * Picks the action for a fresh press: with one hold button down, the binding that uses it; + * with none, the plain binding. Two hold buttons at once is ambiguous and matches nothing. + */ +function matchAction( + compiled: CompiledControllerScreenBinding[], + pressKey: string, + modifiers: string[], +): A | null { + if (modifiers.length > 1) return null; + const holdKey = modifiers[0] ?? null; + return ( + compiled.find((entry) => entry.pressKey === pressKey && entry.holdKey === holdKey)?.action ?? + null + ); +} + +/** + * Controller layout for a dialog described by a screen definition: per step, press-or-combo + * bindings with optional repeat. Actions are dispatched; the dialog decides what they do. + */ +export function createControllerComboScreenLayout(options: { + screen: ControllerScreenDefinition; + getBindings: (config: ResolvedControllerConfig) => Partial>; + getStep: () => S; + dispatch: (action: A) => void; +}): ControllerScreenLayout { + const { screen } = options; + const channels = new Map>(); + const repeatByAction = new Map(screen.actions.map((action) => [action.id, action.repeat])); + + function readFrame(gamepad: GamepadLike, config: ResolvedControllerConfig): ScreenFrame { + const context = getControllerReviewInputContext(config, gamepad.mapping); + const step = options.getStep(); + const bindings = options.getBindings(config); + const compiled = compileControllerScreenBindings(screen, bindings, step, context); + // Track inputs from every step so a press held across a step change is not new. + const sources = new Map(); + for (const { id: anyStep } of screen.steps) { + const entries = + anyStep === step + ? compiled + : compileControllerScreenBindings(screen, bindings, anyStep, context); + for (const entry of entries) { + sources.set(entry.pressKey, entry.press); + if (entry.hold && entry.holdKey) sources.set(entry.holdKey, entry.hold); + } + } + const pressed = new Set(); + for (const [key, source] of sources) { + if (isSourcePressed(source, gamepad, config)) pressed.add(key); + } + const holdKeys = new Set(); + for (const entry of compiled) { + if (entry.holdKey) holdKeys.add(entry.holdKey); + } + return { compiled, pressed, holdKeys }; + } + + function heldModifiers(frame: ScreenFrame, exceptKey: string): string[] { + return [...frame.holdKeys].filter((key) => key !== exceptKey && frame.pressed.has(key)).sort(); + } + + function poll(gamepad: GamepadLike, config: ResolvedControllerConfig, now: number): void { + const frame = readFrame(gamepad, config); + + for (const key of [...channels.keys()]) { + if (!frame.pressed.has(key)) channels.delete(key); + } + + for (const key of frame.pressed) { + const modifiers = heldModifiers(frame, key); + const signature = modifiers.join('|'); + const channel = channels.get(key); + + if (!channel) { + const action = matchAction(frame.compiled, key, modifiers); + const next: PressChannel = { + action, + repeat: action ? (repeatByAction.get(action) ?? false) : false, + modifiers: signature, + latched: false, + hold: createHoldState(), + }; + channels.set(key, next); + if (action) { + shouldFireHeldAction(next.hold, now, config.repeatDelayMs, config.repeatIntervalMs); + options.dispatch(action); + } + continue; + } + + if (channel.latched || channel.action === null) continue; + if (channel.modifiers !== signature) { + // Letting go of L1 a moment before the D-pad must not fire the plain D-pad action. + channel.latched = true; + continue; + } + if ( + channel.repeat && + shouldFireHeldAction(channel.hold, now, config.repeatDelayMs, config.repeatIntervalMs) + ) { + options.dispatch(channel.action); + } + } + } + + function latchHeld(gamepad: GamepadLike, config: ResolvedControllerConfig): void { + channels.clear(); + for (const key of readFrame(gamepad, config).pressed) { + channels.set(key, { + action: null, + repeat: false, + modifiers: '', + latched: true, + hold: createHoldState(), + }); + } + } + + return { + activate(gamepad, config, now, { latchHeld: shouldLatch }) { + if (shouldLatch) { + latchHeld(gamepad, config); + return; + } + channels.clear(); + poll(gamepad, config, now); + }, + poll, + }; +} diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index 6f2b1c90..078c7955 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -3,7 +3,7 @@ import test from 'node:test'; import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core'; import type { ControllerRuntimeSnapshot, ResolvedControllerConfig } from '../../types'; -import { createGamepadController } from './gamepad-controller.js'; +import { createGamepadController, type ControllerScreenLayout } from './gamepad-controller.js'; type ControllerOptions = Parameters[0]; type Bindings = ResolvedControllerConfig['bindings']; @@ -290,6 +290,8 @@ test('gamepad controller uses active controller profile bindings before global b label: 'Profile Pad', buttonIndices: globalConfig.buttonIndices, bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } }, + reviewBindings: globalConfig.reviewBindings, + fieldGroupingBindings: globalConfig.fieldGroupingBindings, }, }, }, @@ -659,3 +661,126 @@ test('gamepad controller requires keyboard-only mode for popup entry navigation' assert.deepEqual(harness.calls, []); }); + +function createRecordingScreenLayout() { + const events: string[] = []; + const layout: ControllerScreenLayout = { + activate: (gamepad, _config, _now, { latchHeld }) => { + events.push(`activate:${gamepad.id}:${latchHeld ? 'latch' : 'fresh'}`); + }, + poll: (_gamepad, _config, now) => { + events.push(`poll:${now}`); + }, + }; + return { layout, events }; +} + +test('gamepad controller hands every input to an open screen layout', () => { + let screenOpen = true; + const { layout, events } = createRecordingScreenLayout(); + const harness = createHarness({ + // A (lookup), Y (keyboard mode) and R2 (secondary hold) are all pressed. + gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: PRESSED } })], + config: createControllerConfig({ bindings: HOLD_ON_R2 }), + deps: { getScreenLayout: () => (screenOpen ? layout : null) }, + }); + + harness.poll(0, 16, 32); + assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'poll:32']); + assert.deepEqual(harness.calls, []); + assert.deepEqual(harness.secondarySubHeld, []); + + // Buttons still held when the screen closes do not fire their normal actions... + screenOpen = false; + harness.poll(48); + assert.deepEqual(harness.calls, []); + // ...but a level-triggered hold resumes, since it reflects what is held right now. + assert.deepEqual(harness.secondarySubHeld, [true]); +}); + +test('gamepad controller re-activates the screen layout for each new session', () => { + let screenOpen = true; + const { layout, events } = createRecordingScreenLayout(); + const harness = createHarness({ + gamepads: [createGamepad('pad-1')], + deps: { getScreenLayout: () => (screenOpen ? layout : null) }, + }); + + harness.poll(0, 16); + screenOpen = false; + harness.poll(32); + screenOpen = true; + harness.poll(48, 64); + + assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'activate:pad-1:latch', 'poll:64']); +}); + +test('gamepad controller ignores the screen layout while controller support is disabled', () => { + const { layout, events } = createRecordingScreenLayout(); + const harness = createHarness({ + gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED } })], + config: createControllerConfig({ enabled: false }), + deps: { getScreenLayout: () => layout }, + }); + + harness.poll(0, 16); + + assert.deepEqual(events, []); +}); + +test('gamepad controller stands down entirely while another window owns the controller', () => { + let suspended = true; + let r2: TestButton = PRESSED; + const harness = createHarness({ + gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })], + config: createControllerConfig({ bindings: HOLD_ON_R2 }), + }); + harness.poll(0); + assert.deepEqual(harness.secondarySubHeld, [true]); + + const suspendedHarness = createHarness({ + gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })], + config: createControllerConfig({ bindings: HOLD_ON_R2 }), + deps: { getSuspended: () => suspended }, + }); + suspendedHarness.poll(0, 16); + // Not even the keyboard-mode toggle (Y) fires, and the hold stays released. + assert.deepEqual(suspendedHarness.calls, []); + assert.deepEqual(suspendedHarness.secondarySubHeld, []); + + suspended = false; + r2 = RELEASED; + suspendedHarness.poll(32); + assert.deepEqual(suspendedHarness.calls, []); +}); + +test('gamepad controller counts the press that reveals a pad to an open screen', () => { + let visible = false; + const { layout, events } = createRecordingScreenLayout(); + const harness = createHarness({ + // Until a button is pressed in a fresh window, the browser reports no pads at all. + gamepads: () => (visible ? [createGamepad('pad-1', { buttons: { 0: PRESSED } })] : []), + deps: { getScreenLayout: () => layout }, + }); + + harness.poll(0, 16); + visible = true; + harness.poll(32, 48); + + assert.deepEqual(events, ['activate:pad-1:fresh', 'poll:48']); +}); + +test('gamepad controller still ignores a button held when the screen opened', () => { + let screenOpen = false; + const { layout, events } = createRecordingScreenLayout(); + const harness = createHarness({ + gamepads: [createGamepad('pad-1', { buttons: { 2: PRESSED } })], + deps: { getScreenLayout: () => (screenOpen ? layout : null) }, + }); + + harness.poll(0); + screenOpen = true; + harness.poll(16); + + assert.deepEqual(events, ['activate:pad-1:latch']); +}); diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index df67563b..087fdd33 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -13,7 +13,7 @@ type ControllerButtonState = { touched?: boolean; }; -type GamepadLike = { +export type GamepadLike = { id: string; index: number; connected: boolean; @@ -22,6 +22,22 @@ type GamepadLike = { buttons: readonly ControllerButtonState[]; }; +/** + * A screen that takes over every controller input while it is open (for example the + * media timing review). `activate` runs on the first poll of a new session; with + * `latchHeld` it ignores buttons already held (so the press that opened the screen does + * not fire), otherwise it treats them as fresh presses. `poll` runs on every later frame. + */ +export type ControllerScreenLayout = { + activate: ( + gamepad: GamepadLike, + config: ResolvedControllerConfig, + now: number, + options: { latchHeld: boolean }, + ) => void; + poll: (gamepad: GamepadLike, config: ResolvedControllerConfig, now: number) => void; +}; + type GamepadControllerOptions = { getGamepads: () => Array; getConfig: () => ResolvedControllerConfig; @@ -44,9 +60,13 @@ type GamepadControllerOptions = { scrollPopup: (deltaPixels: number) => void; jumpPopup: (deltaPixels: number) => void; onState: (state: ControllerRuntimeSnapshot) => void; + /** Screen layout that currently owns the controller in this window, if any. */ + getScreenLayout?: () => ControllerScreenLayout | null; + /** True while a modal in another window owns the controller; this window stands down. */ + getSuspended?: () => boolean; }; -type HoldState = { +export type HoldState = { repeatStarted: boolean; direction: -1 | 1 | null; lastFireAt: number; @@ -65,7 +85,7 @@ const DPAD_AXIS_INDEX = { vertical: 7, } as const; -function normalizeRawButtonState( +export function normalizeRawButtonState( button: ControllerButtonState | undefined, triggerDeadzone: number, ): boolean { @@ -114,7 +134,7 @@ function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number { return Math.sign(value); } -function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number { +export function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number { const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal); if (axisValue !== 0) { return axisValue; @@ -127,7 +147,7 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe ); } -function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number { +export function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number { const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical); if (axisValue !== 0) { return axisValue; @@ -141,7 +161,7 @@ function resolveConnectedGamepads(gamepads: Array): GamepadL .sort((left, right) => left.index - right.index); } -function createHoldState(): HoldState { +export function createHoldState(): HoldState { return { repeatStarted: false, direction: null, @@ -150,7 +170,7 @@ function createHoldState(): HoldState { }; } -function shouldFireHeldAction( +export function shouldFireHeldAction( state: HoldState, now: number, repeatDelayMs: number, @@ -173,7 +193,7 @@ function shouldFireHeldAction( return true; } -function resetHeldAction(state: HoldState): void { +export function resetHeldAction(state: HoldState): void { state.repeatStarted = false; state.direction = null; state.lastFireAt = 0; @@ -198,7 +218,7 @@ function syncHeldActionBlocked( state.initialFired = true; } -function resolveDiscreteBindingPressed( +export function resolveDiscreteBindingPressed( gamepad: GamepadLike, binding: ResolvedControllerDiscreteBinding, config: ResolvedControllerConfig, @@ -263,6 +283,8 @@ export function createGamepadController(options: GamepadControllerOptions) { let activeGamepadId: string | null = null; let lastPollAt: number | null = null; let secondarySubHeld = false; + let activeScreenLayout: ControllerScreenLayout | null = null; + let screenLayoutWaitingForPad = false; function getConnectedGamepads(): GamepadLike[] { return resolveConnectedGamepads(options.getGamepads()); @@ -423,6 +445,11 @@ export function createGamepadController(options: GamepadControllerOptions) { if (!activeGamepad) { syncSecondarySubHold(false); + activeScreenLayout = null; + // Browsers hide a pad from a page until a button is pressed while it is open, and + // each review opens in a fresh window. When a screen is already waiting for the pad, + // the press that reveals it started after the screen opened, so it should count. + screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null; previousActions = new Map(); resetHeldAction(selectionHold); resetHeldAction(jumpHold); @@ -431,6 +458,7 @@ export function createGamepadController(options: GamepadControllerOptions) { } if (activeGamepad.id !== previousActiveGamepadId) { + activeScreenLayout = null; previousActions = new Map(); resetHeldAction(selectionHold); resetHeldAction(jumpHold); @@ -438,6 +466,31 @@ export function createGamepadController(options: GamepadControllerOptions) { const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id); + // 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 + // keeps tracking the pad, so a button still held when control returns does not fire. + const screenLayout = activeConfig.enabled ? (options.getScreenLayout?.() ?? null) : null; + const suspended = screenLayout === null && (options.getSuspended?.() ?? false); + if (screenLayout !== null || suspended) { + syncSecondarySubHold(false); + syncBlockedInteractionState(activeGamepad, activeConfig, now); + if (screenLayout === null) { + activeScreenLayout = null; + screenLayoutWaitingForPad = false; + } else if (screenLayout !== activeScreenLayout) { + activeScreenLayout = screenLayout; + screenLayout.activate(activeGamepad, activeConfig, now, { + latchHeld: !screenLayoutWaitingForPad, + }); + screenLayoutWaitingForPad = false; + } else { + screenLayout.poll(activeGamepad, activeConfig, now); + } + return; + } + activeScreenLayout = null; + screenLayoutWaitingForPad = false; + // Unlike most actions, this one does not require keyboard-only mode, so it can be // used while simply watching. syncSecondarySubHold( diff --git a/src/renderer/handlers/media-timing-review-controller.test.ts b/src/renderer/handlers/media-timing-review-controller.test.ts new file mode 100644 index 00000000..c7645912 --- /dev/null +++ b/src/renderer/handlers/media-timing-review-controller.test.ts @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core'; +import { + CONTROLLER_REVIEW_ACTIONS, + findControllerReviewBindingIssues, +} from '../../shared/controller-review'; +import type { ResolvedControllerConfig } from '../../types'; +import type { GamepadLike } from './gamepad-controller.js'; +import { + createMediaTimingReviewControllerLayout, + formatMediaTimingReviewControllerHints, + getControllerReviewInputContext, + type MediaTimingReviewControllerAction, + type MediaTimingReviewStep, +} from './media-timing-review-controller.js'; + +const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true }; +const { repeatDelayMs, repeatIntervalMs } = CONFIG; + +// Standard-layout indices; the layout reads them from config.buttonIndices. +const BUTTON = { A: 0, B: 1, X: 2, Y: 3, L1: 4, R1: 5, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 }; +type ButtonName = keyof typeof BUTTON; + +function pad(pressed: ButtonName[] = [], axes: number[] = [0, 0, 0, 0]): GamepadLike { + const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false })); + for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true }; + return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes, buttons }; +} + +function createLayout(step: MediaTimingReviewStep = 'editor') { + const actions: MediaTimingReviewControllerAction[] = []; + const state = { step }; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => state.step, + dispatch: (action) => actions.push(action), + }); + layout.activate(pad(), CONFIG, 0, { latchHeld: true }); + return { layout, actions, state }; +} + +test('the default review layout binds all 21 actions without conflicts', () => { + assert.equal(CONTROLLER_REVIEW_ACTIONS.length, 21); + for (const action of CONTROLLER_REVIEW_ACTIONS) { + assert.equal(CONFIG.reviewBindings[action.id]?.kind, 'combo', action.id); + } + for (const mapping of ['standard', ''] as const) { + for (const faceButtonLabels of ['xbox', 'nintendo'] as const) { + const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping); + assert.deepEqual(findControllerReviewBindingIssues(CONFIG.reviewBindings, context), {}); + } + } +}); + +test('controller hints read like the documented layout', () => { + const hints = formatMediaTimingReviewControllerHints(CONFIG, 'standard'); + assert.equal(hints.confirm, 'A'); + assert.equal(hints.showEarlier, '\u25c0'); + assert.equal(hints.startForward, 'L1 + \u25b6'); + assert.equal(hints.nextLineAdd, 'R1 + \u25b2'); + assert.equal(hints.frameReset, 'L1 + R1'); +}); + +test('face buttons press the editor buttons once per press', () => { + const { layout, actions } = createLayout(); + + layout.poll(pad(['A']), CONFIG, 10); + layout.poll(pad(['A']), CONFIG, 900); + layout.poll(pad(), CONFIG, 910); + layout.poll(pad(['B', 'X', 'Y']), CONFIG, 920); + + assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']); +}); + +test('face buttons press the cancel-step buttons on that step', () => { + const { layout, actions } = createLayout('cancel'); + + layout.poll(pad(['A', 'B', 'X', 'Y']), CONFIG, 10); + + assert.deepEqual(actions, ['useOriginal', 'keepEditing', 'skipMedia', 'discard']); +}); + +test('buttons already held when the review opens wait for a fresh press', () => { + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + + // X mined the card that opened this review; it must not also start the preview. + layout.activate(pad(['X', 'RIGHT']), CONFIG, 0, { latchHeld: true }); + layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_000); + assert.deepEqual(actions, []); + + layout.poll(pad(), CONFIG, 1_010); + layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_020); + assert.deepEqual(actions, ['play', 'showLater']); +}); + +test('D-pad alone moves the timeline and steps screenshot frames', () => { + const { layout, actions } = createLayout(); + + for (const name of ['LEFT', 'RIGHT', 'UP', 'DOWN'] as const) { + layout.poll(pad([name]), CONFIG, 10); + layout.poll(pad(), CONFIG, 20); + } + + assert.deepEqual(actions, ['showEarlier', 'showLater', 'framePrevious', 'frameNext']); +}); + +test('L1 combos adjust the start and previous lines, R1 combos the end and next lines', () => { + const { layout, actions } = createLayout(); + const press = (names: ButtonName[]) => { + layout.poll(pad(names), CONFIG, 10); + layout.poll(pad(), CONFIG, 20); + }; + + press(['L1', 'LEFT']); + press(['L1', 'RIGHT']); + press(['L1', 'UP']); + press(['L1', 'DOWN']); + press(['R1', 'LEFT']); + press(['R1', 'RIGHT']); + press(['R1', 'UP']); + press(['R1', 'DOWN']); + + assert.deepEqual(actions, [ + 'startBack', + 'startForward', + 'previousLineAdd', + 'previousLineRemove', + 'endBack', + 'endForward', + 'nextLineAdd', + 'nextLineRemove', + ]); +}); + +test('holding a D-pad combo repeats after the configured delay', () => { + const { layout, actions } = createLayout(); + const hold = (now: number) => layout.poll(pad(['L1', 'RIGHT']), CONFIG, now); + + hold(1_000); + hold(1_000 + repeatDelayMs - 1); + hold(1_000 + repeatDelayMs); + hold(1_000 + repeatDelayMs + repeatIntervalMs - 1); + hold(1_000 + repeatDelayMs + repeatIntervalMs); + + assert.deepEqual(actions, ['startForward', 'startForward', 'startForward']); +}); + +test('letting go of L1 before the D-pad does not fire the plain D-pad action', () => { + const { layout, actions } = createLayout(); + + layout.poll(pad(['L1', 'RIGHT']), CONFIG, 10); + layout.poll(pad(['RIGHT']), CONFIG, 20); + layout.poll(pad(['RIGHT']), CONFIG, 2_000); + assert.deepEqual(actions, ['startForward']); + + layout.poll(pad(), CONFIG, 2_010); + layout.poll(pad(['RIGHT']), CONFIG, 2_020); + assert.deepEqual(actions, ['startForward', 'showLater']); +}); + +test('pressing L1 and R1 together resets the screenshot frame once', () => { + const { layout, actions } = createLayout(); + + layout.poll(pad(['L1']), CONFIG, 10); + layout.poll(pad(['L1', 'R1']), CONFIG, 20); + layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 30); + layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 2_000); + + assert.deepEqual(actions, ['frameReset']); +}); + +test('D-pads reported as axes (non-standard pads) drive the same layout', () => { + const { layout, actions } = createLayout(); + const axes = (horizontal: number, vertical: number) => [0, 0, 0, 0, 0, 0, horizontal, vertical]; + + layout.poll(pad(['R1'], axes(-1, 0)), CONFIG, 10); + layout.poll(pad([], axes(0, 0)), CONFIG, 20); + layout.poll(pad([], axes(0, 1)), CONFIG, 30); + + assert.deepEqual(actions, ['endBack', 'frameNext']); +}); + +test('Nintendo-style pads press the button labelled A to confirm, wherever it sits', () => { + const nintendo: ResolvedControllerConfig = { ...CONFIG, faceButtonLabels: 'nintendo' }; + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + layout.activate(pad(), nintendo, 0, { latchHeld: true }); + + // Positions, not letters: on these pads A is the right button (index 1), B the bottom + // one (0), X the top one (3) and Y the left one (2). + for (const position of ['B', 'A', 'Y', 'X'] as const) { + layout.poll(pad([position]), nintendo, 10); + layout.poll(pad(), nintendo, 20); + } + + assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']); +}); + +test('a press that first reveals the pad to a fresh dialog window counts', () => { + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + + layout.activate(pad(['A']), CONFIG, 0, { latchHeld: false }); + layout.poll(pad(['A']), CONFIG, 16); + + assert.deepEqual(actions, ['confirm']); +}); + +function withReviewBindings( + overrides: Partial, +): ResolvedControllerConfig { + return { ...CONFIG, reviewBindings: { ...CONFIG.reviewBindings, ...overrides } }; +} + +test('remapped combos use any hold button and leave plain presses alone', () => { + const config = withReviewBindings({ + // Hold L2 (raw button 6 on standard pads) and press A. + startForward: { + kind: 'combo', + press: { kind: 'face', letter: 'a' }, + hold: { kind: 'button', buttonIndex: 6 }, + }, + }); + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + const press = (indices: number[], now: number) => { + const buttons = Array.from({ length: 17 }, (_, index) => ({ + value: indices.includes(index) ? 1 : 0, + pressed: indices.includes(index), + })); + layout.poll({ ...pad(), buttons }, config, now); + }; + layout.activate(pad(), config, 0, { latchHeld: true }); + + press([6], 10); + press([6, 0], 20); + press([], 30); + press([0], 40); + + assert.deepEqual(actions, ['startForward', 'confirm']); +}); + +test('unbound review actions never fire', () => { + const config = withReviewBindings({ confirm: { kind: 'none' } }); + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + layout.activate(pad(), config, 0, { latchHeld: true }); + + layout.poll(pad(['A']), config, 10); + + assert.deepEqual(actions, []); +}); + +test('repeat follows the action, not the button it is on', () => { + const config = withReviewBindings({ + startForward: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null }, + cancel: { kind: 'combo', press: { kind: 'dpad', direction: 'right' }, hold: null }, + showLater: { kind: 'none' }, + }); + const actions: MediaTimingReviewControllerAction[] = []; + const layout = createMediaTimingReviewControllerLayout({ + getStep: () => 'editor', + dispatch: (action) => actions.push(action), + }); + layout.activate(pad(), config, 0, { latchHeld: true }); + + for (const now of [10, 10 + repeatDelayMs, 10 + repeatDelayMs + repeatIntervalMs]) { + layout.poll(pad(['B', 'RIGHT']), config, now); + } + + // Both fire on the first press; only startForward repeats. + assert.deepEqual(actions, ['cancel', 'startForward', 'startForward', 'startForward']); +}); + +test('conflicts are reported, including a learned D-pad button that matches a default', () => { + const config = withReviewBindings({ + // A learned raw D-pad left (button 14) is the same input as the default "D-pad ◀". + showLater: { kind: 'combo', press: { kind: 'button', buttonIndex: 14 }, hold: null }, + // R1 alone, while other actions hold R1. + play: { kind: 'combo', press: { kind: 'button', buttonIndex: 5 }, hold: null }, + }); + const issues = findControllerReviewBindingIssues( + config.reviewBindings, + getControllerReviewInputContext(config, 'standard'), + ); + + assert.equal(issues.showLater, 'Same as Earlier −2s'); + assert.equal(issues.showEarlier, 'Same as Later +2s'); + assert.equal(issues.play, 'R1 is also a hold button on this step'); +}); diff --git a/src/renderer/handlers/media-timing-review-controller.ts b/src/renderer/handlers/media-timing-review-controller.ts new file mode 100644 index 00000000..2c5730be --- /dev/null +++ b/src/renderer/handlers/media-timing-review-controller.ts @@ -0,0 +1,43 @@ +import { + CONTROLLER_REVIEW_SCREEN, + type ControllerReviewStep, +} from '../../shared/controller-review'; +import type { ControllerReviewAction, ResolvedControllerConfig } from '../../types'; +import { + createControllerComboScreenLayout, + formatControllerScreenHints, +} from './controller-screen-layout.js'; +import type { ControllerScreenLayout } from './gamepad-controller.js'; + +export { getControllerReviewInputContext } from './controller-screen-layout.js'; + +/** Every button on the media timing review screen, named by what it does. */ +export type MediaTimingReviewControllerAction = ControllerReviewAction; + +/** The review shows either the timing editor or the "Stop reviewing?" step. */ +export type MediaTimingReviewStep = ControllerReviewStep; + +/** Hint text per action for the review buttons, e.g. `A` or `L1 + ▶`; null when unbound. */ +export function formatMediaTimingReviewControllerHints( + config: ResolvedControllerConfig, + mapping: string | null | undefined, +): Record { + return formatControllerScreenHints( + CONTROLLER_REVIEW_SCREEN, + config.reviewBindings, + config, + mapping, + ); +} + +export function createMediaTimingReviewControllerLayout(options: { + getStep: () => MediaTimingReviewStep; + dispatch: (action: MediaTimingReviewControllerAction) => void; +}): ControllerScreenLayout { + return createControllerComboScreenLayout({ + screen: CONTROLLER_REVIEW_SCREEN, + getBindings: (config) => config.reviewBindings, + getStep: options.getStep, + dispatch: options.dispatch, + }); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 782bc385..805149f4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -569,20 +569,20 @@
-
1 — Original Card
+
1 — Original Card
-
2 — New Card
+
2 — New Card