mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Controller: layouts for the media timing review and duplicate card dialogs (#313)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
696462a82e
commit
6d0f37ac21
50 files changed
+4555
-46
No files matched your search
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
+13
-12
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<ControllerScreenId, string> = {
|
||||
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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, (event: unknown, ...args: unknown[]) => 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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
||||
type RawControllerScreenBindings = Record<string, unknown>;
|
||||
/** Per-dialog controller layouts stored as per-action patches. */
|
||||
const SCREEN_BINDING_KEYS = ['reviewBindings', 'fieldGroupingBindings'] as const;
|
||||
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
|
||||
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
|
||||
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
|
||||
@@ -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<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>> | undefined,
|
||||
update: Partial<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>>,
|
||||
target: Partial<Record<(typeof SCREEN_BINDING_KEYS)[number], unknown>>,
|
||||
): 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<
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -186,6 +186,10 @@ const onSessionNumericSelectionStartEvent =
|
||||
IPC_CHANNELS.event.sessionNumericSelectionStart,
|
||||
(payload) => payload as SessionNumericSelectionStartPayload,
|
||||
);
|
||||
const onControllerSuspendedChangedEvent = createQueuedIpcListenerWithPayload<boolean>(
|
||||
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<PlaylistBrowserSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
|
||||
|
||||
@@ -18,5 +18,7 @@ export function resolveControllerConfigForGamepad(
|
||||
...config,
|
||||
buttonIndices: profile.buttonIndices,
|
||||
bindings: profile.bindings,
|
||||
reviewBindings: profile.reviewBindings ?? config.reviewBindings,
|
||||
fieldGroupingBindings: profile.fieldGroupingBindings ?? config.fieldGroupingBindings,
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
@@ -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<ControllerReviewInput, { kind: 'button' | 'axis' }>;
|
||||
|
||||
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<string>();
|
||||
const restingAxes = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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<A extends string>(
|
||||
screen: ControllerScreenDefinition<A>,
|
||||
bindings: Partial<ControllerScreenBindings<A>>,
|
||||
config: ResolvedControllerConfig,
|
||||
mapping: string | null | undefined,
|
||||
): Record<A, string | null> {
|
||||
const context = getControllerReviewInputContext(config, mapping);
|
||||
const hints = {} as Record<A, string | null>;
|
||||
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<A extends string> = {
|
||||
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<A extends string> = {
|
||||
compiled: CompiledControllerScreenBinding<A>[];
|
||||
pressed: Set<string>;
|
||||
holdKeys: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<A extends string>(
|
||||
compiled: CompiledControllerScreenBinding<A>[],
|
||||
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<A extends string, S extends string>(options: {
|
||||
screen: ControllerScreenDefinition<A, S>;
|
||||
getBindings: (config: ResolvedControllerConfig) => Partial<ControllerScreenBindings<A>>;
|
||||
getStep: () => S;
|
||||
dispatch: (action: A) => void;
|
||||
}): ControllerScreenLayout {
|
||||
const { screen } = options;
|
||||
const channels = new Map<string, PressChannel<A>>();
|
||||
const repeatByAction = new Map(screen.actions.map((action) => [action.id, action.repeat]));
|
||||
|
||||
function readFrame(gamepad: GamepadLike, config: ResolvedControllerConfig): ScreenFrame<A> {
|
||||
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<string, ControllerReviewInputSource>();
|
||||
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<string>();
|
||||
for (const [key, source] of sources) {
|
||||
if (isSourcePressed(source, gamepad, config)) pressed.add(key);
|
||||
}
|
||||
const holdKeys = new Set<string>();
|
||||
for (const entry of compiled) {
|
||||
if (entry.holdKey) holdKeys.add(entry.holdKey);
|
||||
}
|
||||
return { compiled, pressed, holdKeys };
|
||||
}
|
||||
|
||||
function heldModifiers(frame: ScreenFrame<A>, 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<A> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createGamepadController>[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']);
|
||||
});
|
||||
@@ -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<GamepadLike | null>;
|
||||
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<GamepadLike | null>): 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(
|
||||
|
||||
@@ -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['reviewBindings']>,
|
||||
): 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');
|
||||
});
|
||||
@@ -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<MediaTimingReviewControllerAction, string | null> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -569,20 +569,20 @@
|
||||
</div>
|
||||
<div class="kiku-cards-container">
|
||||
<div id="kikuCard1" class="kiku-card active" tabindex="0">
|
||||
<div class="kiku-card-label">1 — Original Card</div>
|
||||
<div id="kikuCard1Label" class="kiku-card-label">1 — Original Card</div>
|
||||
<div class="kiku-card-expression" id="kikuCard1Expression"></div>
|
||||
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
|
||||
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
|
||||
</div>
|
||||
<div id="kikuCard2" class="kiku-card" tabindex="0">
|
||||
<div class="kiku-card-label">2 — New Card</div>
|
||||
<div id="kikuCard2Label" class="kiku-card-label">2 — New Card</div>
|
||||
<div class="kiku-card-expression" id="kikuCard2Expression"></div>
|
||||
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
|
||||
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kiku-footer">
|
||||
<label class="kiku-delete-toggle">
|
||||
<label id="kikuDeleteToggle" class="kiku-delete-toggle">
|
||||
<input id="kikuDeleteDuplicate" type="checkbox" checked />
|
||||
Delete duplicate card after merge
|
||||
</label>
|
||||
@@ -597,8 +597,11 @@
|
||||
|
||||
<div id="kikuPreviewStep" class="hidden">
|
||||
<div class="kiku-preview-header">
|
||||
<div class="kiku-preview-title">Final Merge Preview</div>
|
||||
<div class="kiku-preview-toggle">
|
||||
<div class="kiku-preview-title">
|
||||
Final Merge Preview
|
||||
<span id="kikuPreviewScrollHint" class="kiku-preview-scroll-hint">Scroll</span>
|
||||
</div>
|
||||
<div id="kikuPreviewToggle" class="kiku-preview-toggle">
|
||||
<button id="kikuPreviewCompact" type="button">Compact</button>
|
||||
<button id="kikuPreviewFull" type="button">Full</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import type { ControllerScreenLearning } from './controller-config-form';
|
||||
import { createControllerConfigForm } from './controller-config-form.js';
|
||||
|
||||
type ActionId = keyof ResolvedControllerConfig['bindings'];
|
||||
@@ -91,7 +96,11 @@ function findOne(root: FakeElement, className: string, text?: string): FakeEleme
|
||||
/** Renders the form into a fake container with `document` stubbed for the duration. */
|
||||
function renderForm(
|
||||
learningActionId: ActionId | null,
|
||||
options: { bindings?: typeof BINDINGS; mapping?: string } = {},
|
||||
options: {
|
||||
bindings?: typeof BINDINGS;
|
||||
mapping?: string;
|
||||
faceButtonLabels?: 'xbox' | 'nintendo';
|
||||
} = {},
|
||||
) {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
@@ -106,6 +115,7 @@ function renderForm(
|
||||
container: container as unknown as HTMLElement,
|
||||
getBindings: () => options.bindings ?? BINDINGS,
|
||||
getControllerMapping: () => options.mapping,
|
||||
getFaceButtonLabels: () => options.faceButtonLabels,
|
||||
getLearningActionId: () => learningActionId,
|
||||
getDpadLearningActionId: () => null,
|
||||
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
||||
@@ -263,3 +273,213 @@ test('controller config form names buttons by the W3C standard layout for standa
|
||||
assert.equal(badge(raw.row('Quit MPV')), 'Back / Select');
|
||||
assert.equal(badge(raw.row('Previous Entry')), 'L3 / LS');
|
||||
});
|
||||
|
||||
test('controller config form names face buttons Nintendo-style when configured', () => {
|
||||
const badge = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
|
||||
const bindings: typeof BINDINGS = {
|
||||
...BINDINGS,
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
};
|
||||
|
||||
const form = renderForm(null, { bindings, mapping: 'standard', faceButtonLabels: 'nintendo' });
|
||||
assert.equal(badge(form.row('Toggle Lookup')), 'B');
|
||||
assert.equal(badge(form.row('Close Lookup')), 'A');
|
||||
assert.equal(badge(form.row('Mine Card')), 'Y');
|
||||
assert.equal(badge(form.row('Toggle Keyboard-Only Mode')), 'X');
|
||||
// Non-face buttons keep their names.
|
||||
assert.equal(badge(form.row('Next Audio')), 'RB / R1');
|
||||
});
|
||||
|
||||
type ReviewFormOptions = {
|
||||
reviewBindings?: ResolvedControllerConfig['reviewBindings'];
|
||||
fieldGroupingBindings?: ResolvedControllerConfig['fieldGroupingBindings'];
|
||||
learning?: ControllerScreenLearning | null;
|
||||
};
|
||||
|
||||
/** Renders the form with the dialog groups; `act` runs while `document` is still stubbed. */
|
||||
function withReviewForm(
|
||||
options: ReviewFormOptions,
|
||||
act: (env: {
|
||||
calls: string[];
|
||||
container: FakeElement;
|
||||
reviewRow: (label: string) => FakeElement;
|
||||
}) => void,
|
||||
): void {
|
||||
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { createElement: () => new FakeElement() },
|
||||
});
|
||||
try {
|
||||
const calls: string[] = [];
|
||||
const container = new FakeElement();
|
||||
createControllerConfigForm({
|
||||
container: container as unknown as HTMLElement,
|
||||
getBindings: () => BINDINGS,
|
||||
getLearningActionId: () => null,
|
||||
getDpadLearningActionId: () => null,
|
||||
onLearn: () => {},
|
||||
onClear: () => {},
|
||||
onReset: () => {},
|
||||
onDpadLearn: () => {},
|
||||
onDpadClear: () => {},
|
||||
onDpadReset: () => {},
|
||||
getScreenGroups: () => {
|
||||
const bindings = {
|
||||
reviewBindings: options.reviewBindings ?? getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings:
|
||||
options.fieldGroupingBindings ?? getDefaultControllerFieldGroupingBindings(),
|
||||
};
|
||||
return CONTROLLER_SCREENS.map((screen) => ({
|
||||
screen,
|
||||
bindings: bindings[screen.configKey],
|
||||
}));
|
||||
},
|
||||
getScreenInputContext: () => ({
|
||||
mapping: 'standard',
|
||||
faceButtonLabels: 'xbox',
|
||||
buttonIndices: CORE_DEFAULT_CONFIG.controller.buttonIndices,
|
||||
}),
|
||||
getScreenLearning: () => options.learning ?? null,
|
||||
onScreenLearn: (screenId, actionId) => calls.push(`learn:${screenId}:${actionId}`),
|
||||
onScreenClear: (screenId, actionId) => calls.push(`clear:${screenId}:${actionId}`),
|
||||
onScreenReset: (screenId, actionId) => calls.push(`reset:${screenId}:${actionId}`),
|
||||
onScreenResetAll: (screenId) => calls.push(`resetAll:${screenId}`),
|
||||
}).render();
|
||||
const reviewRow = (label: string) => {
|
||||
const match = findAll(container, 'controller-config-row').find(
|
||||
(el) => findOne(el, 'controller-config-label').textContent === label,
|
||||
);
|
||||
assert.ok(match, `missing row "${label}"`);
|
||||
return match;
|
||||
};
|
||||
act({ calls, container, reviewRow });
|
||||
} finally {
|
||||
if (previousDocument) {
|
||||
Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const badgeOf = (row: FakeElement) => findOne(row, 'controller-config-badge').textContent;
|
||||
|
||||
test('controller config form keeps the review group collapsed until opened', () => {
|
||||
withReviewForm({}, ({ calls, container, reviewRow }) => {
|
||||
const header = findOne(container, 'controller-config-group-toggle');
|
||||
assert.match(header.textContent, /Review media timing/);
|
||||
assert.equal(
|
||||
findAll(container, 'controller-config-subgroup').length,
|
||||
0,
|
||||
'collapsed group shows no rows',
|
||||
);
|
||||
|
||||
header.click();
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-subgroup').map((el) => el.textContent),
|
||||
['While editing', 'After Cancel'],
|
||||
);
|
||||
assert.equal(badgeOf(reviewRow('Use this timing')), 'A');
|
||||
assert.equal(badgeOf(reviewRow('Earlier −2s')), 'D-pad ◀');
|
||||
assert.equal(badgeOf(reviewRow('Start +0.1s')), 'L1 + D-pad ▶');
|
||||
assert.equal(badgeOf(reviewRow('Reset screenshot frame')), 'L1 + R1');
|
||||
assert.equal(badgeOf(reviewRow('Keep editing')), 'B');
|
||||
|
||||
findOne(container, 'controller-config-group-action', 'Reset all').click();
|
||||
findOne(reviewRow('Start +0.1s'), 'controller-config-edit-icon').click();
|
||||
findOne(reviewRow('Start +0.1s'), 'controller-config-reset-icon').click();
|
||||
assert.deepEqual(calls, [
|
||||
'resetAll:review',
|
||||
'learn:review:startForward',
|
||||
'reset:review:startForward',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('controller config form shows the hold button while a review combo is being learned', () => {
|
||||
withReviewForm(
|
||||
{ learning: { screenId: 'review', actionId: 'startForward', holdLabel: 'L2' } },
|
||||
({ container, reviewRow }) => {
|
||||
assert.equal(badgeOf(reviewRow('Start +0.1s')), 'L2 + …');
|
||||
assert.match(
|
||||
findOne(container, 'controller-config-edit-hint').textContent,
|
||||
/Holding L2: press another button for a combo, or let go to use L2 alone/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('controller config form warns about review bindings that clash', () => {
|
||||
const reviewBindings = {
|
||||
...getDefaultControllerReviewBindings(),
|
||||
play: {
|
||||
kind: 'combo' as const,
|
||||
press: { kind: 'face' as const, letter: 'a' as const },
|
||||
hold: null,
|
||||
},
|
||||
};
|
||||
withReviewForm({ reviewBindings }, ({ container, reviewRow }) => {
|
||||
findOne(container, 'controller-config-group-toggle').click();
|
||||
const warnings = findAll(container, 'controller-config-warning').map((el) => el.textContent);
|
||||
assert.deepEqual(warnings, ['⚠ Same as Play selection', '⚠ Same as Use this timing']);
|
||||
assert.equal(reviewRow('Play selection').classList.contains('has-warning'), true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The dialog group headers, in order. */
|
||||
const groupHeaders = (container: FakeElement) =>
|
||||
findAll(container, 'controller-config-group-toggle');
|
||||
|
||||
test('controller config form lists the duplicate card layout as its own group', () => {
|
||||
withReviewForm({}, ({ calls, container, reviewRow }) => {
|
||||
const headers = groupHeaders(container);
|
||||
assert.deepEqual(
|
||||
headers.map((el) => el.textContent.replace(/Reset all$/, '')),
|
||||
['▸ Review media timing', '▸ Duplicate card (field grouping)'],
|
||||
);
|
||||
|
||||
headers[1]!.click();
|
||||
assert.deepEqual(
|
||||
findAll(container, 'controller-config-subgroup').map((el) => el.textContent),
|
||||
['Choosing the card', 'Merge preview'],
|
||||
'only the opened group shows rows',
|
||||
);
|
||||
assert.equal(badgeOf(reviewRow('Original card')), 'D-pad ◀');
|
||||
assert.equal(badgeOf(reviewRow('New card')), 'D-pad ▶');
|
||||
assert.equal(badgeOf(reviewRow('Delete duplicate on/off')), 'X');
|
||||
assert.equal(badgeOf(reviewRow('Continue')), 'A');
|
||||
assert.equal(badgeOf(reviewRow('Confirm merge')), 'A');
|
||||
assert.equal(badgeOf(reviewRow('Back')), 'B');
|
||||
assert.equal(badgeOf(reviewRow('Compact / Full')), 'Y');
|
||||
assert.equal(badgeOf(reviewRow('Scroll down')), 'D-pad ▼');
|
||||
// Cancel on the preview is unbound by default (B goes back, B again cancels).
|
||||
const cancels = findAll(container, 'controller-config-row').filter(
|
||||
(el) => findOne(el, 'controller-config-label').textContent === 'Cancel',
|
||||
);
|
||||
assert.deepEqual(cancels.map(badgeOf), ['B', 'None']);
|
||||
assert.equal(findAll(container, 'controller-config-warning').length, 0);
|
||||
|
||||
findOne(container, 'controller-config-group-action', 'Reset all').click();
|
||||
findOne(reviewRow('Continue'), 'controller-config-edit-icon').click();
|
||||
assert.deepEqual(calls, ['resetAll:fieldGrouping', 'learn:fieldGrouping:continue']);
|
||||
});
|
||||
});
|
||||
|
||||
test('learning a duplicate card action opens only that group', () => {
|
||||
withReviewForm(
|
||||
{ learning: { screenId: 'fieldGrouping', actionId: 'back', holdLabel: null } },
|
||||
({ container }) => {
|
||||
const headers = groupHeaders(container);
|
||||
assert.equal(headers[0]!.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(headers[1]!.getAttribute('aria-expanded'), 'true');
|
||||
assert.match(
|
||||
findOne(container, 'controller-config-edit-hint').textContent,
|
||||
/Press a button, or hold one and press another for a combo/,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,26 @@
|
||||
import {
|
||||
findControllerScreenBindingIssues,
|
||||
formatControllerReviewBinding,
|
||||
type ControllerReviewInputContext,
|
||||
type ControllerScreenActionDefinition,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ControllerScreenId } from '../../shared/controller-screens';
|
||||
import type { ResolvedControllerReviewBinding } from '../../types';
|
||||
|
||||
/** A dialog layout shown as a collapsible group in Alt+C. */
|
||||
export type ControllerScreenGroup = {
|
||||
screen: ControllerScreenDefinition;
|
||||
bindings: ControllerScreenBindings;
|
||||
};
|
||||
|
||||
/** The dialog action being learned, and the hold button pressed so far, if any. */
|
||||
export type ControllerScreenLearning = {
|
||||
screenId: ControllerScreenId;
|
||||
actionId: string;
|
||||
holdLabel: string | null;
|
||||
};
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
@@ -215,6 +238,10 @@ const W3C_STANDARD_AXIS_NAMES: Record<number, string> = {
|
||||
};
|
||||
|
||||
type ControllerMapping = string | null | undefined;
|
||||
type FaceButtonLabels = ResolvedControllerConfig['faceButtonLabels'] | undefined;
|
||||
|
||||
/** Nintendo-style pads print B at the bottom and A on the right; positions 0-3 are the same. */
|
||||
const NINTENDO_FACE_BUTTON_NAMES: Record<number, string> = { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' };
|
||||
|
||||
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
||||
none: 'None',
|
||||
@@ -222,7 +249,14 @@ const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
||||
vertical: 'D-pad \u2195',
|
||||
};
|
||||
|
||||
export function getFriendlyButtonName(buttonIndex: number, mapping?: ControllerMapping): string {
|
||||
export function getFriendlyButtonName(
|
||||
buttonIndex: number,
|
||||
mapping?: ControllerMapping,
|
||||
faceButtonLabels?: FaceButtonLabels,
|
||||
): string {
|
||||
const nintendoName =
|
||||
faceButtonLabels === 'nintendo' ? NINTENDO_FACE_BUTTON_NAMES[buttonIndex] : undefined;
|
||||
if (nintendoName) return nintendoName;
|
||||
const names = mapping === 'standard' ? W3C_STANDARD_BUTTON_NAMES : RAW_LAYOUT_BUTTON_NAMES;
|
||||
return names[buttonIndex] ?? `Button ${buttonIndex}`;
|
||||
}
|
||||
@@ -261,13 +295,16 @@ function formatFriendlyStickLabel(
|
||||
function formatFriendlyBindingLabel(
|
||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
||||
mapping: ControllerMapping,
|
||||
faceButtonLabels: FaceButtonLabels,
|
||||
): string {
|
||||
if (binding.kind === 'none') return 'None';
|
||||
if ('direction' in binding) {
|
||||
const name = getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
|
||||
}
|
||||
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex, mapping);
|
||||
if ('buttonIndex' in binding) {
|
||||
return getFriendlyButtonName(binding.buttonIndex, mapping, faceButtonLabels);
|
||||
}
|
||||
return getFriendlyAxisName(binding.axisIndex, mapping);
|
||||
}
|
||||
|
||||
@@ -287,8 +324,20 @@ export function createControllerConfigForm(options: {
|
||||
onDpadReset: (actionId: ControllerBindingActionId) => void;
|
||||
/** Gamepad API `mapping` of the controller being configured; picks the button names. */
|
||||
getControllerMapping?: () => ControllerMapping;
|
||||
/** `controller.faceButtonLabels`; names the face buttons as printed on the pad. */
|
||||
getFaceButtonLabels?: () => FaceButtonLabels;
|
||||
/** Dialog layouts (review, field grouping) of the controller being configured. */
|
||||
getScreenGroups?: () => ControllerScreenGroup[];
|
||||
getScreenInputContext?: () => ControllerReviewInputContext;
|
||||
getScreenLearning?: () => ControllerScreenLearning | null;
|
||||
onScreenLearn?: (screenId: ControllerScreenId, actionId: string) => void;
|
||||
onScreenClear?: (screenId: ControllerScreenId, actionId: string) => void;
|
||||
onScreenReset?: (screenId: ControllerScreenId, actionId: string) => void;
|
||||
onScreenResetAll?: (screenId: ControllerScreenId) => void;
|
||||
}) {
|
||||
let expandedRowKey: ExpandedRowKey | null = null;
|
||||
/** Dialog groups start collapsed; this holds the opened ones. */
|
||||
const expandedScreens = new Set<ControllerScreenId>();
|
||||
|
||||
function render(): void {
|
||||
options.container.innerHTML = '';
|
||||
@@ -325,6 +374,127 @@ export function createControllerConfigForm(options: {
|
||||
renderDiscreteRow(definition, binding, learningActionId);
|
||||
}
|
||||
}
|
||||
|
||||
const context = options.getScreenInputContext?.();
|
||||
if (context) {
|
||||
for (const group of options.getScreenGroups?.() ?? []) {
|
||||
renderScreenGroup(group, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderScreenGroup(
|
||||
{ screen, bindings }: ControllerScreenGroup,
|
||||
context: ControllerReviewInputContext,
|
||||
): void {
|
||||
const learning = options.getScreenLearning?.() ?? null;
|
||||
const learningHere = learning?.screenId === screen.id ? learning : null;
|
||||
if (learningHere) {
|
||||
expandedScreens.add(screen.id);
|
||||
expandedRowKey = `${screen.id}:${learningHere.actionId}`;
|
||||
}
|
||||
const expanded = expandedScreens.has(screen.id);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'controller-config-group controller-config-group-toggle';
|
||||
header.setAttribute('role', 'button');
|
||||
header.setAttribute('aria-expanded', String(expanded));
|
||||
header.textContent = `${expanded ? '\u25be' : '\u25b8'} ${screen.title}`;
|
||||
header.addEventListener('click', () => {
|
||||
if (expanded) expandedScreens.delete(screen.id);
|
||||
else expandedScreens.add(screen.id);
|
||||
render();
|
||||
});
|
||||
if (expanded) {
|
||||
const resetAll = document.createElement('button');
|
||||
resetAll.type = 'button';
|
||||
resetAll.className = 'btn-secondary controller-config-group-action';
|
||||
resetAll.textContent = 'Reset all';
|
||||
resetAll.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
options.onScreenResetAll?.(screen.id);
|
||||
});
|
||||
header.appendChild(resetAll);
|
||||
}
|
||||
options.container.appendChild(header);
|
||||
if (!expanded) return;
|
||||
|
||||
const issues = findControllerScreenBindingIssues(screen, bindings, context);
|
||||
for (const step of screen.steps) {
|
||||
const subgroup = document.createElement('div');
|
||||
subgroup.className = 'controller-config-subgroup';
|
||||
subgroup.textContent = step.label;
|
||||
options.container.appendChild(subgroup);
|
||||
for (const action of screen.actions) {
|
||||
if (action.step !== step.id) continue;
|
||||
renderScreenRow(
|
||||
screen,
|
||||
action,
|
||||
bindings[action.id] ?? { kind: 'none' },
|
||||
context,
|
||||
issues[action.id],
|
||||
learningHere,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderScreenRow(
|
||||
screen: ControllerScreenDefinition,
|
||||
action: ControllerScreenActionDefinition,
|
||||
binding: ResolvedControllerReviewBinding,
|
||||
context: ControllerReviewInputContext,
|
||||
issue: string | undefined,
|
||||
learning: ControllerScreenLearning | null,
|
||||
): void {
|
||||
const rowKey = `${screen.id}:${action.id}`;
|
||||
const isExpanded = expandedRowKey === rowKey;
|
||||
const isLearning = learning?.actionId === action.id;
|
||||
const holdLabel = isLearning ? learning.holdLabel : null;
|
||||
const current = formatControllerReviewBinding(binding, context, { dpadPrefix: true });
|
||||
|
||||
const learn = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
expandedRowKey = rowKey;
|
||||
options.onScreenLearn?.(screen.id, action.id);
|
||||
};
|
||||
const reset = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
options.onScreenReset?.(screen.id, action.id);
|
||||
};
|
||||
const row = createRow(
|
||||
action.label,
|
||||
holdLabel ? `${holdLabel} + \u2026` : current,
|
||||
binding.kind === 'none',
|
||||
isExpanded,
|
||||
`Learn ${action.label}`,
|
||||
learn,
|
||||
`Reset ${action.label}`,
|
||||
reset,
|
||||
issue,
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
render();
|
||||
});
|
||||
options.container.appendChild(row);
|
||||
|
||||
if (isExpanded) {
|
||||
const hint = !isLearning
|
||||
? `Currently: ${current}`
|
||||
: holdLabel
|
||||
? `Holding ${holdLabel}: press another button for a combo, or let go to use ${holdLabel} alone\u2026`
|
||||
: 'Press a button, or hold one and press another for a combo\u2026';
|
||||
const panel = createEditPanel(hint, isLearning, {
|
||||
onLearn: learn,
|
||||
onClear: (e) => {
|
||||
e.stopPropagation();
|
||||
options.onScreenClear?.(screen.id, action.id);
|
||||
},
|
||||
onReset: reset,
|
||||
});
|
||||
options.container.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscreteRow(
|
||||
@@ -338,7 +508,11 @@ export function createControllerConfigForm(options: {
|
||||
|
||||
const row = createRow(
|
||||
definition.label,
|
||||
formatFriendlyBindingLabel(binding, options.getControllerMapping?.()),
|
||||
formatFriendlyBindingLabel(
|
||||
binding,
|
||||
options.getControllerMapping?.(),
|
||||
options.getFaceButtonLabels?.(),
|
||||
),
|
||||
binding.kind === 'none',
|
||||
isExpanded,
|
||||
`Learn ${definition.label}`,
|
||||
@@ -500,6 +674,7 @@ export function createControllerConfigForm(options: {
|
||||
onEdit: (e: Event) => void,
|
||||
resetLabel: string,
|
||||
onReset: (e: Event) => void,
|
||||
warningText?: string,
|
||||
): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'controller-config-row';
|
||||
@@ -508,6 +683,13 @@ export function createControllerConfigForm(options: {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'controller-config-label';
|
||||
label.textContent = labelText;
|
||||
if (warningText) {
|
||||
row.classList.add('has-warning');
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'controller-config-warning';
|
||||
warning.textContent = `\u26a0 ${warningText}`;
|
||||
label.appendChild(warning);
|
||||
}
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'controller-config-right';
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerDebugModal } from './controller-debug.js';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
@@ -46,6 +48,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
@@ -79,6 +82,8 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
@@ -164,6 +169,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
@@ -197,6 +203,8 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
@@ -281,6 +289,7 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
@@ -314,6 +323,8 @@ test('controller debug modal stays closed and notifies when controller support i
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
};
|
||||
const ctx = {
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from 'node:test';
|
||||
|
||||
import { createRendererState } from '../state.js';
|
||||
import { createControllerSelectModal } from './controller-select.js';
|
||||
import { getDefaultControllerReviewBindings } from '../../shared/controller-review';
|
||||
import { getDefaultControllerFieldGroupingBindings } from '../../shared/controller-field-grouping';
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
@@ -128,6 +130,7 @@ function buildContext() {
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
@@ -161,6 +164,8 @@ function buildContext() {
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
@@ -328,6 +333,8 @@ test('controller select modal reset control stores the default binding in the se
|
||||
...state.controllerConfig.bindings,
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
reviewBindings: state.controllerConfig.reviewBindings,
|
||||
fieldGroupingBindings: state.controllerConfig.fieldGroupingBindings,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -442,6 +449,8 @@ test('controller select modal learn mode falls back to global bindings without a
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
// No controller visible and none saved: edits go to the global bindings.
|
||||
state.controllerConfig!.preferredGamepadId = '';
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
@@ -617,3 +626,274 @@ test('controller select modal uses unique picker values for duplicate controller
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
function findByClass(root: any, className: string, text?: string): any {
|
||||
for (const child of root.children ?? []) {
|
||||
const classes = String(child.className ?? '').split(' ');
|
||||
if (classes.includes(className) && (text === undefined || child.textContent === text)) {
|
||||
return child;
|
||||
}
|
||||
const nested = findByClass(child, className, text);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findReviewRow(root: any, label: string): any {
|
||||
for (const child of root.children ?? []) {
|
||||
if (String(child.className).split(' ').includes('controller-config-row')) {
|
||||
if (child.children[0]?.textContent === label) return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('controller select modal learns a review combo and saves it to the controller profile', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const header = findByClass(dom.controllerConfigList, 'controller-config-group-toggle');
|
||||
assert.ok(header, 'review group header');
|
||||
header.dispatch('click');
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Start +0.1s');
|
||||
assert.ok(row, 'Start +0.1s row');
|
||||
row.children[1].children[0].dispatch('click');
|
||||
|
||||
const buttons = (pressed: number[]) =>
|
||||
Array.from({ length: 17 }, (_, index) => ({
|
||||
value: pressed.includes(index) ? 1 : 0,
|
||||
pressed: pressed.includes(index),
|
||||
touched: pressed.includes(index),
|
||||
}));
|
||||
|
||||
// Hold L2, then press A.
|
||||
state.controllerRawButtons = buttons([6]);
|
||||
modal.updateDevices();
|
||||
assert.equal(
|
||||
findReviewRow(dom.controllerConfigList, 'Start +0.1s').children[1].children[0].textContent,
|
||||
'L2 + …',
|
||||
);
|
||||
state.controllerRawButtons = buttons([6, 0]);
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
reviewBindings: {
|
||||
startForward: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'button', buttonIndex: 0 },
|
||||
hold: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.reviewBindings.startForward, {
|
||||
kind: 'combo',
|
||||
press: { kind: 'button', buttonIndex: 0 },
|
||||
hold: { kind: 'button', buttonIndex: 6 },
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
function findAllByClass(root: any, className: string, found: any[] = []): any[] {
|
||||
for (const child of root.children ?? []) {
|
||||
if (
|
||||
String(child.className ?? '')
|
||||
.split(' ')
|
||||
.includes(className)
|
||||
)
|
||||
found.push(child);
|
||||
findAllByClass(child, className, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
test('controller select modal learns a duplicate card button and saves it to the profile', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const headers = findAllByClass(dom.controllerConfigList, 'controller-config-group-toggle');
|
||||
assert.equal(headers.length, 2, 'review and duplicate card groups');
|
||||
headers[1].dispatch('click');
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Back');
|
||||
assert.ok(row, 'Back row');
|
||||
row.children[1].children[0].dispatch('click');
|
||||
|
||||
const buttons = (pressed: number[]) =>
|
||||
Array.from({ length: 17 }, (_, index) => ({
|
||||
value: pressed.includes(index) ? 1 : 0,
|
||||
pressed: pressed.includes(index),
|
||||
touched: pressed.includes(index),
|
||||
}));
|
||||
|
||||
// Press and release Select.
|
||||
state.controllerRawButtons = buttons([8]);
|
||||
modal.updateDevices();
|
||||
state.controllerRawButtons = buttons([]);
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
const back = { kind: 'combo', press: { kind: 'button', buttonIndex: 8 }, hold: null };
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: { 'pad-1': { fieldGroupingBindings: { back } } },
|
||||
});
|
||||
const profile = state.controllerConfig?.profiles['pad-1'];
|
||||
assert.deepEqual(profile?.fieldGroupingBindings.back, back);
|
||||
// Other dialogs keep their layout.
|
||||
assert.deepEqual(profile?.reviewBindings, state.controllerConfig?.reviewBindings);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal shows and edits the saved controller before this window sees it', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
// A fresh Alt+C window has not seen the pad yet (no button pressed in it).
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const config = state.controllerConfig!;
|
||||
config.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
buttonIndices: config.buttonIndices,
|
||||
bindings: config.bindings,
|
||||
fieldGroupingBindings: config.fieldGroupingBindings,
|
||||
reviewBindings: {
|
||||
...config.reviewBindings,
|
||||
startBack: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'button', buttonIndex: 15 },
|
||||
hold: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
findByClass(dom.controllerConfigList, 'controller-config-group-toggle').dispatch('click');
|
||||
const row = findReviewRow(dom.controllerConfigList, 'Start \u22120.1s');
|
||||
assert.equal(row.children[1].children[0].textContent, 'L2 + D-pad \u25b6');
|
||||
const waiting = 'Press any button on your controller so this window can see it.';
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
// Controller polling keeps publishing snapshots while no pad is visible.
|
||||
modal.updateDevices();
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting, 'the prompt stays');
|
||||
|
||||
// Reset goes to the saved controller's profile, not the global layout.
|
||||
row.children[1].children[1].dispatch('click');
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
reviewBindings: {
|
||||
startBack: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'dpad', direction: 'left' },
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const afterReset = dom.controllerSelectStatus.textContent;
|
||||
assert.equal(afterReset, 'Start \u22120.1s reset.');
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, afterReset, 'the reset message stays');
|
||||
|
||||
// The pad appears, then disappears again.
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
modal.updateDevices();
|
||||
assert.equal(dom.controllerSelectStatus.textContent, waiting);
|
||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
||||
state.activeGamepadId = 'pad-1';
|
||||
modal.updateDevices();
|
||||
assert.equal(
|
||||
dom.controllerSelectStatus.textContent,
|
||||
'Choose a controller or click Learn to remap an action.',
|
||||
);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import { createControllerComboCapture } from '../handlers/controller-combo-capture.js';
|
||||
import {
|
||||
formatControllerReviewInput,
|
||||
getControllerScreenAction,
|
||||
getDefaultControllerScreenBindings,
|
||||
type ControllerReviewInputContext,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
} from '../../shared/controller-review';
|
||||
import {
|
||||
CONTROLLER_SCREENS,
|
||||
getControllerScreen,
|
||||
type ControllerScreenBindingsKey,
|
||||
} from '../../shared/controller-screens';
|
||||
import {
|
||||
createControllerConfigForm,
|
||||
getControllerBindingDefinition,
|
||||
@@ -20,6 +34,11 @@ function clampSelectedIndex(ctx: RendererContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const NO_CONTROLLER_STATUS = 'No controllers detected.';
|
||||
const WAITING_FOR_SAVED_CONTROLLER_STATUS =
|
||||
'Press any button on your controller so this window can see it.';
|
||||
const NO_CONTROLLER_STATUSES = new Set([NO_CONTROLLER_STATUS, WAITING_FOR_SAVED_CONTROLLER_STATUS]);
|
||||
|
||||
export function createControllerSelectModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
@@ -39,13 +58,24 @@ export function createControllerSelectModal(
|
||||
let learningActionId: ControllerBindingKey | null = null;
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||
let screenLearning: {
|
||||
screen: ControllerScreenDefinition;
|
||||
actionId: string;
|
||||
capture: ReturnType<typeof createControllerComboCapture>;
|
||||
holdLabel: string | null;
|
||||
} | null = null;
|
||||
|
||||
function getSelectedController() {
|
||||
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The controller whose profile Alt+C shows and edits. A freshly opened window cannot see
|
||||
* any pad until a button is pressed in it, so until then use the saved (preferred)
|
||||
* controller rather than the global defaults it does not actually use.
|
||||
*/
|
||||
function getSelectedControllerId(): string | null {
|
||||
return getSelectedController()?.id ?? null;
|
||||
return getSelectedController()?.id ?? (ctx.state.controllerConfig?.preferredGamepadId || null);
|
||||
}
|
||||
|
||||
function getSelectedControllerConfig() {
|
||||
@@ -54,9 +84,39 @@ export function createControllerSelectModal(
|
||||
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
|
||||
}
|
||||
|
||||
function getScreenInputContext(): ControllerReviewInputContext {
|
||||
const config = getSelectedControllerConfig();
|
||||
return {
|
||||
mapping: getSelectedController()?.mapping ?? 'standard',
|
||||
faceButtonLabels: config?.faceButtonLabels ?? 'xbox',
|
||||
buttonIndices: config?.buttonIndices ?? {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cancelScreenLearning(): void {
|
||||
screenLearning = null;
|
||||
}
|
||||
|
||||
function actionLabel(screen: ControllerScreenDefinition, actionId: string): string {
|
||||
return getControllerScreenAction(screen, actionId).label;
|
||||
}
|
||||
|
||||
const controllerConfigForm = createControllerConfigForm({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
getControllerMapping: () => getSelectedController()?.mapping ?? null,
|
||||
getFaceButtonLabels: () => ctx.state.controllerConfig?.faceButtonLabels,
|
||||
getBindings: () =>
|
||||
getSelectedControllerConfig()?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
@@ -76,12 +136,71 @@ export function createControllerSelectModal(
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
getScreenGroups: () => {
|
||||
const config = getSelectedControllerConfig();
|
||||
if (!config) return [];
|
||||
return CONTROLLER_SCREENS.map((screen) => ({
|
||||
screen,
|
||||
bindings: config[screen.configKey] as ControllerScreenBindings,
|
||||
}));
|
||||
},
|
||||
getScreenInputContext,
|
||||
getScreenLearning: () =>
|
||||
screenLearning
|
||||
? {
|
||||
screenId: screenLearning.screen.id,
|
||||
actionId: screenLearning.actionId,
|
||||
holdLabel: screenLearning.holdLabel,
|
||||
}
|
||||
: null,
|
||||
onScreenLearn: (screenId, actionId) => {
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
const config = ctx.state.controllerConfig;
|
||||
const capture = createControllerComboCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
capture.arm({ axes: ctx.state.controllerRawAxes, buttons: ctx.state.controllerRawButtons });
|
||||
const screen = getControllerScreen(screenId);
|
||||
screenLearning = { screen, actionId, capture, holdLabel: null };
|
||||
controllerConfigForm.render();
|
||||
setStatus(
|
||||
`Press a button for ${actionLabel(screen, actionId)}, or hold one and press another for a combo.`,
|
||||
);
|
||||
},
|
||||
onScreenClear: (screenId, actionId) => {
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
{ [actionId]: { kind: 'none' } },
|
||||
`${actionLabel(screen, actionId)} cleared.`,
|
||||
);
|
||||
},
|
||||
onScreenReset: (screenId, actionId) => {
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
{ [actionId]: structuredClone(screen.defaults[actionId]!) },
|
||||
`${actionLabel(screen, actionId)} reset.`,
|
||||
);
|
||||
},
|
||||
onScreenResetAll: (screenId) => {
|
||||
const screen = getControllerScreen(screenId);
|
||||
void saveScreenBindings(
|
||||
screen,
|
||||
getDefaultControllerScreenBindings(screen),
|
||||
`${screen.title} layout reset.`,
|
||||
);
|
||||
},
|
||||
getLearningActionId: () => learningActionId,
|
||||
getDpadLearningActionId: () => dpadLearningActionId,
|
||||
onLearn: (actionId, bindingType) => {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
dpadLearningActionId = null;
|
||||
cancelScreenLearning();
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
@@ -122,6 +241,7 @@ export function createControllerSelectModal(
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
if (!definition) return;
|
||||
learningActionId = null;
|
||||
cancelScreenLearning();
|
||||
const config = ctx.state.controllerConfig;
|
||||
bindingCapture = createControllerBindingCapture({
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
@@ -236,6 +356,15 @@ export function createControllerSelectModal(
|
||||
...update.bindings,
|
||||
} as typeof ctx.state.controllerConfig.bindings;
|
||||
}
|
||||
const config = ctx.state.controllerConfig;
|
||||
for (const { configKey } of CONTROLLER_SCREENS) {
|
||||
if (update[configKey]) {
|
||||
(config as Record<ControllerScreenBindingsKey, unknown>)[configKey] = {
|
||||
...config[configKey],
|
||||
...update[configKey],
|
||||
};
|
||||
}
|
||||
}
|
||||
if (update.profiles) {
|
||||
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
|
||||
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
|
||||
@@ -244,6 +373,8 @@ export function createControllerSelectModal(
|
||||
label: profileUpdate.label ?? profileId,
|
||||
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
||||
bindings: ctx.state.controllerConfig.bindings,
|
||||
reviewBindings: ctx.state.controllerConfig.reviewBindings,
|
||||
fieldGroupingBindings: ctx.state.controllerConfig.fieldGroupingBindings,
|
||||
};
|
||||
ctx.state.controllerConfig.profiles[profileId] = {
|
||||
label: profileUpdate.label ?? baseProfile.label,
|
||||
@@ -255,6 +386,15 @@ export function createControllerSelectModal(
|
||||
...baseProfile.bindings,
|
||||
...(profileUpdate.bindings ?? {}),
|
||||
},
|
||||
reviewBindings: {
|
||||
...(baseProfile.reviewBindings ?? ctx.state.controllerConfig.reviewBindings),
|
||||
...(profileUpdate.reviewBindings ?? {}),
|
||||
},
|
||||
fieldGroupingBindings: {
|
||||
...(baseProfile.fieldGroupingBindings ??
|
||||
ctx.state.controllerConfig.fieldGroupingBindings),
|
||||
...(profileUpdate.fieldGroupingBindings ?? {}),
|
||||
},
|
||||
} as (typeof ctx.state.controllerConfig.profiles)[string];
|
||||
}
|
||||
}
|
||||
@@ -264,8 +404,8 @@ export function createControllerSelectModal(
|
||||
actionId: ControllerBindingKey,
|
||||
binding: ControllerBindingValue,
|
||||
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
|
||||
const selected = getSelectedController();
|
||||
if (!selected) {
|
||||
const profileId = getSelectedControllerId();
|
||||
if (!profileId) {
|
||||
return {
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
@@ -274,7 +414,7 @@ export function createControllerSelectModal(
|
||||
}
|
||||
return {
|
||||
profiles: {
|
||||
[selected.id]: {
|
||||
[profileId]: {
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
@@ -301,6 +441,24 @@ export function createControllerSelectModal(
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScreenBindings(
|
||||
screen: ControllerScreenDefinition,
|
||||
bindings: Partial<ControllerScreenBindings>,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const profileId = getSelectedControllerId();
|
||||
const patch = { [screen.configKey]: bindings };
|
||||
try {
|
||||
await saveControllerConfig(profileId ? { profiles: { [profileId]: patch } } : patch);
|
||||
cancelScreenLearning();
|
||||
controllerConfigForm.render();
|
||||
setStatus(message);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(`Failed to save ${screen.title} binding: ${message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDpadFallback(
|
||||
actionId: ControllerBindingKey,
|
||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||
@@ -376,6 +534,30 @@ export function createControllerSelectModal(
|
||||
}
|
||||
}
|
||||
|
||||
if (screenLearning) {
|
||||
const learning = screenLearning;
|
||||
const state = learning.capture.poll({
|
||||
axes: ctx.state.controllerRawAxes,
|
||||
buttons: ctx.state.controllerRawButtons,
|
||||
});
|
||||
if (state.status === 'holding') {
|
||||
const holdLabel = formatControllerReviewInput(state.hold, getScreenInputContext(), {
|
||||
dpadPrefix: true,
|
||||
});
|
||||
if (holdLabel !== learning.holdLabel) {
|
||||
learning.holdLabel = holdLabel;
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
} else if (state.status === 'done') {
|
||||
screenLearning = null;
|
||||
void saveScreenBindings(
|
||||
learning.screen,
|
||||
{ [learning.actionId]: state.binding },
|
||||
`${actionLabel(learning.screen, learning.actionId)} updated.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||
const shouldRender =
|
||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
||||
@@ -386,11 +568,26 @@ export function createControllerSelectModal(
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
|
||||
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
|
||||
setStatus('No controllers detected.');
|
||||
// Polling publishes a snapshot every frame, pad or not: only a change in the devices
|
||||
// sets the no-pad status, so it neither replaces the prompt nor a message like "Reset".
|
||||
const idle = !learningActionId && !dpadLearningActionId && !screenLearning;
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
if (idle && shouldRender) setStatus(getNoControllerStatus());
|
||||
} else if (
|
||||
idle &&
|
||||
NO_CONTROLLER_STATUSES.has(ctx.dom.controllerSelectStatus.textContent ?? '')
|
||||
) {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
}
|
||||
|
||||
/** A saved controller is only waiting for a press; without one there is nothing to show. */
|
||||
function getNoControllerStatus(): string {
|
||||
return ctx.state.controllerConfig?.preferredGamepadId
|
||||
? WAITING_FOR_SAVED_CONTROLLER_STATUS
|
||||
: NO_CONTROLLER_STATUS;
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): boolean {
|
||||
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||
options.notifyControllerDisabled();
|
||||
@@ -407,7 +604,7 @@ export function createControllerSelectModal(
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
if (ctx.state.connectedGamepads.length === 0) {
|
||||
setStatus('No controllers detected.');
|
||||
setStatus(getNoControllerStatus());
|
||||
} else {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
@@ -419,6 +616,7 @@ export function createControllerSelectModal(
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
cancelScreenLearning();
|
||||
ctx.state.controllerSelectModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
||||
@@ -432,10 +630,11 @@ export function createControllerSelectModal(
|
||||
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (learningActionId || dpadLearningActionId) {
|
||||
if (learningActionId || dpadLearningActionId || screenLearning) {
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
cancelScreenLearning();
|
||||
controllerConfigForm.render();
|
||||
setStatus('Controller learn mode cancelled.');
|
||||
return true;
|
||||
@@ -472,7 +671,7 @@ export function createControllerSelectModal(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
|
||||
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId && !screenLearning) {
|
||||
event.preventDefault();
|
||||
void saveSelectedController();
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../../types';
|
||||
import { createKikuModal, formatKikuScrollHint, KIKU_CONTROLLER_SCROLL_STEP_PX } from './kiku';
|
||||
|
||||
class FakeClassList {
|
||||
private readonly names = new Set<string>();
|
||||
add(name: string): void {
|
||||
this.names.add(name);
|
||||
}
|
||||
remove(name: string): void {
|
||||
this.names.delete(name);
|
||||
}
|
||||
toggle(name: string, force?: boolean): void {
|
||||
if (force ?? !this.names.has(name)) this.names.add(name);
|
||||
else this.names.delete(name);
|
||||
}
|
||||
contains(name: string): boolean {
|
||||
return this.names.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
classList = new FakeClassList();
|
||||
dataset: Record<string, string | undefined> = {};
|
||||
textContent = '';
|
||||
checked = false;
|
||||
disabled = false;
|
||||
scrollTop = 0;
|
||||
setAttribute(): void {}
|
||||
addEventListener(): void {}
|
||||
}
|
||||
|
||||
const CARD = (noteId: number): KikuDuplicateCardInfo => ({
|
||||
noteId,
|
||||
expression: '猫',
|
||||
sentencePreview: '',
|
||||
hasAudio: true,
|
||||
hasImage: false,
|
||||
isOriginal: noteId === 1,
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const dom = new Proxy({} as Record<string, FakeElement>, {
|
||||
get: (target, key: string) => (target[key] ??= new FakeElement()),
|
||||
});
|
||||
const state = {
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1 as 1 | 2,
|
||||
kikuOriginalData: null,
|
||||
kikuDuplicateData: null,
|
||||
kikuPendingChoice: null,
|
||||
kikuPreviewCompactData: null,
|
||||
kikuPreviewFullData: null,
|
||||
kikuPreviewMode: 'compact' as 'compact' | 'full',
|
||||
kikuModalStep: 'select' as 'select' | 'preview',
|
||||
isOverSubtitle: false,
|
||||
};
|
||||
const responses: KikuFieldGroupingChoice[] = [];
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
kikuBuildMergePreview: async () => ({ ok: true, compact: { a: 1 }, full: { a: 1, b: 2 } }),
|
||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => responses.push(choice),
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const modal = createKikuModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
});
|
||||
modal.openKikuFieldGroupingModal({ original: CARD(1), duplicate: CARD(2) });
|
||||
return {
|
||||
dom,
|
||||
state,
|
||||
modal,
|
||||
responses,
|
||||
restore: () => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('controller actions walk the duplicate card dialog like the keyboard does', async () => {
|
||||
const { dom, state, modal, responses, restore } = setup();
|
||||
try {
|
||||
assert.equal(modal.getControllerStep(), 'select');
|
||||
assert.equal(modal.handleControllerAction('selectNew'), true);
|
||||
assert.equal(state.kikuSelectedCard, 2);
|
||||
assert.equal(dom.kikuCard2!.classList.contains('active'), true);
|
||||
assert.equal(dom.kikuDeleteDuplicateCheckbox!.checked, true);
|
||||
modal.handleControllerAction('toggleDeleteDuplicate');
|
||||
assert.equal(dom.kikuDeleteDuplicateCheckbox!.checked, false);
|
||||
// Preview-only actions do nothing on the first step.
|
||||
assert.equal(modal.handleControllerAction('confirmMerge'), false);
|
||||
|
||||
modal.handleControllerAction('continue');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(modal.getControllerStep(), 'preview');
|
||||
|
||||
modal.handleControllerAction('scrollDown');
|
||||
modal.handleControllerAction('scrollDown');
|
||||
modal.handleControllerAction('scrollUp');
|
||||
assert.equal(dom.kikuPreviewJson!.scrollTop, KIKU_CONTROLLER_SCROLL_STEP_PX);
|
||||
modal.handleControllerAction('toggleView');
|
||||
assert.equal(state.kikuPreviewMode, 'full');
|
||||
assert.equal(modal.handleControllerAction('cancel'), false, 'first-step Cancel is hidden');
|
||||
|
||||
modal.handleControllerAction('back');
|
||||
assert.equal(modal.getControllerStep(), 'select');
|
||||
modal.handleControllerAction('continue');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
modal.handleControllerAction('confirmMerge');
|
||||
assert.deepEqual(responses, [
|
||||
{ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false },
|
||||
]);
|
||||
assert.equal(state.kikuModalOpen, false);
|
||||
assert.equal(modal.handleControllerAction('continue'), false, 'closed dialog ignores input');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Continue is ignored while the merge preview is being built', () => {
|
||||
const { dom, modal, restore } = setup();
|
||||
try {
|
||||
dom.kikuConfirmButton!.disabled = true;
|
||||
assert.equal(modal.handleControllerAction('continue'), false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller hint tags land on the cards, buttons and preview header', () => {
|
||||
const { dom, modal, restore } = setup();
|
||||
try {
|
||||
modal.setControllerHints({
|
||||
selectOriginal: '◀',
|
||||
selectNew: '▶',
|
||||
toggleDeleteDuplicate: 'X',
|
||||
continue: 'A',
|
||||
cancel: 'B',
|
||||
confirmMerge: 'A',
|
||||
back: 'B',
|
||||
cancelMerge: null,
|
||||
toggleView: 'Y',
|
||||
scrollUp: '▲',
|
||||
scrollDown: '▼',
|
||||
});
|
||||
assert.equal(dom.kikuCard1Label!.dataset.controllerHint, '◀');
|
||||
assert.equal(dom.kikuCard2Label!.dataset.controllerHint, '▶');
|
||||
assert.equal(dom.kikuDeleteToggle!.dataset.controllerHint, 'X');
|
||||
assert.equal(dom.kikuConfirmButton!.dataset.controllerHint, 'A');
|
||||
assert.equal(dom.kikuBackButton!.dataset.controllerHint, 'B');
|
||||
assert.equal(dom.kikuFinalCancelButton!.dataset.controllerHint, undefined);
|
||||
assert.equal(dom.kikuPreviewToggle!.dataset.controllerHint, 'Y');
|
||||
assert.equal(dom.kikuPreviewScrollHint!.dataset.controllerHint, '▲ ▼');
|
||||
|
||||
modal.setControllerHintsVisible(true);
|
||||
assert.equal(dom.kikuModal!.classList.contains('controller-hints-visible'), true);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the scroll tag shows whichever directions are bound', () => {
|
||||
assert.equal(formatKikuScrollHint('▲', '▼'), '▲ ▼');
|
||||
assert.equal(formatKikuScrollHint(null, 'RS ▼'), 'RS ▼');
|
||||
assert.equal(formatKikuScrollHint(null, null), null);
|
||||
});
|
||||
@@ -1,9 +1,48 @@
|
||||
import type { ControllerFieldGroupingStep } from '../../shared/controller-field-grouping';
|
||||
import type {
|
||||
ControllerFieldGroupingAction,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import type { RendererDom } from '../utils/dom';
|
||||
|
||||
/** Pixels the merge preview moves per controller scroll step (repeats while held). */
|
||||
export const KIKU_CONTROLLER_SCROLL_STEP_PX = 48;
|
||||
|
||||
/**
|
||||
* Where each controller action's hint tag is drawn. The two scroll actions share one
|
||||
* "Scroll" tag in the preview header instead.
|
||||
*/
|
||||
export const KIKU_CONTROLLER_HINT_TARGETS: Record<
|
||||
Exclude<ControllerFieldGroupingAction, 'scrollUp' | 'scrollDown'>,
|
||||
keyof RendererDom
|
||||
> = {
|
||||
selectOriginal: 'kikuCard1Label',
|
||||
selectNew: 'kikuCard2Label',
|
||||
toggleDeleteDuplicate: 'kikuDeleteToggle',
|
||||
continue: 'kikuConfirmButton',
|
||||
cancel: 'kikuCancelButton',
|
||||
confirmMerge: 'kikuFinalConfirmButton',
|
||||
back: 'kikuBackButton',
|
||||
cancelMerge: 'kikuFinalCancelButton',
|
||||
toggleView: 'kikuPreviewToggle',
|
||||
};
|
||||
|
||||
/** One tag for both scroll directions, e.g. `▲ ▼`; null when neither is bound. */
|
||||
export function formatKikuScrollHint(up: string | null, down: string | null): string | null {
|
||||
if (up && down) return `${up} ${down}`;
|
||||
return up ?? down ?? null;
|
||||
}
|
||||
|
||||
function setHint(element: HTMLElement, hint: string | null | undefined): void {
|
||||
if (hint === null || hint === undefined) {
|
||||
if (element.dataset.controllerHint !== undefined) delete element.dataset.controllerHint;
|
||||
} else if (element.dataset.controllerHint !== hint) {
|
||||
element.dataset.controllerHint = hint;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKikuModal(
|
||||
ctx: RendererContext,
|
||||
@@ -176,6 +215,78 @@ export function createKikuModal(
|
||||
setKikuModalStep('select');
|
||||
}
|
||||
|
||||
function getControllerStep(): ControllerFieldGroupingStep {
|
||||
return ctx.state.kikuModalStep;
|
||||
}
|
||||
|
||||
function selectCard(card: 1 | 2): void {
|
||||
ctx.state.kikuSelectedCard = card;
|
||||
updateKikuCardSelection();
|
||||
}
|
||||
|
||||
/** Runs a controller action with the same guards as the matching click or key. */
|
||||
function handleControllerAction(action: ControllerFieldGroupingAction): boolean {
|
||||
if (!ctx.state.kikuModalOpen) return false;
|
||||
const onPreview = ctx.state.kikuModalStep === 'preview';
|
||||
switch (action) {
|
||||
case 'selectOriginal':
|
||||
case 'selectNew':
|
||||
if (onPreview) return false;
|
||||
selectCard(action === 'selectOriginal' ? 1 : 2);
|
||||
return true;
|
||||
case 'toggleDeleteDuplicate':
|
||||
if (onPreview) return false;
|
||||
ctx.dom.kikuDeleteDuplicateCheckbox.checked = !ctx.dom.kikuDeleteDuplicateCheckbox.checked;
|
||||
return true;
|
||||
case 'continue':
|
||||
// Disabled while the merge preview is being built.
|
||||
if (onPreview || ctx.dom.kikuConfirmButton.disabled) return false;
|
||||
void confirmKikuSelection();
|
||||
return true;
|
||||
case 'cancel':
|
||||
case 'cancelMerge':
|
||||
if (onPreview !== (action === 'cancelMerge')) return false;
|
||||
cancelKikuFieldGrouping();
|
||||
return true;
|
||||
case 'confirmMerge':
|
||||
if (!onPreview) return false;
|
||||
confirmKikuMerge();
|
||||
return true;
|
||||
case 'back':
|
||||
if (!onPreview) return false;
|
||||
goBackFromKikuPreview();
|
||||
return true;
|
||||
case 'toggleView':
|
||||
if (!onPreview) return false;
|
||||
ctx.state.kikuPreviewMode = ctx.state.kikuPreviewMode === 'compact' ? 'full' : 'compact';
|
||||
renderKikuPreview();
|
||||
return true;
|
||||
case 'scrollUp':
|
||||
case 'scrollDown':
|
||||
if (!onPreview) return false;
|
||||
ctx.dom.kikuPreviewJson.scrollTop +=
|
||||
action === 'scrollUp' ? -KIKU_CONTROLLER_SCROLL_STEP_PX : KIKU_CONTROLLER_SCROLL_STEP_PX;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hint tags are drawn by CSS from `data-controller-hint`. Unbound actions show no tag;
|
||||
* only changed attributes are written, since this runs every frame.
|
||||
*/
|
||||
function setControllerHints(hints: Record<ControllerFieldGroupingAction, string | null>): void {
|
||||
for (const [action, domKey] of Object.entries(KIKU_CONTROLLER_HINT_TARGETS) as Array<
|
||||
[keyof typeof KIKU_CONTROLLER_HINT_TARGETS, keyof RendererDom]
|
||||
>) {
|
||||
setHint(ctx.dom[domKey] as HTMLElement, hints[action]);
|
||||
}
|
||||
setHint(ctx.dom.kikuPreviewScrollHint, formatKikuScrollHint(hints.scrollUp, hints.scrollDown));
|
||||
}
|
||||
|
||||
function setControllerHintsVisible(visible: boolean): void {
|
||||
ctx.dom.kikuModal.classList.toggle('controller-hints-visible', visible);
|
||||
}
|
||||
|
||||
function cancelKikuFieldGrouping(): void {
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 0,
|
||||
@@ -292,7 +403,11 @@ export function createKikuModal(
|
||||
return {
|
||||
cancelKikuFieldGrouping,
|
||||
closeKikuFieldGroupingModal,
|
||||
getControllerStep,
|
||||
handleControllerAction,
|
||||
handleKikuKeydown,
|
||||
setControllerHints,
|
||||
setControllerHintsVisible,
|
||||
openKikuFieldGroupingModal,
|
||||
wireDomEvents,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { CONTROLLER_REVIEW_ACTIONS } from '../../shared/controller-review';
|
||||
import {
|
||||
MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS,
|
||||
isMediaTimingReviewButtonAvailable,
|
||||
buildMediaTimingLineSelection,
|
||||
buildMediaTimingWaveformPath,
|
||||
constrainMediaTimingSelection,
|
||||
@@ -155,3 +159,21 @@ test('trailing silence trim follows the last speech slice and keeps cut-off or s
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('every controller action presses a real button on the review screen', () => {
|
||||
const html = readFileSync('src/renderer/index.html', 'utf8');
|
||||
const actions = new Set<string>(CONTROLLER_REVIEW_ACTIONS.map((action) => action.id));
|
||||
assert.deepEqual(new Set(Object.keys(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS)), actions);
|
||||
for (const domId of Object.values(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS)) {
|
||||
assert.match(html, new RegExp(`<button\\s+id="${domId}"`), `missing <button id="${domId}">`);
|
||||
}
|
||||
});
|
||||
|
||||
test('controller presses skip disabled buttons and buttons on the hidden step', () => {
|
||||
const button = (disabled: boolean, insideHidden: boolean) =>
|
||||
({ disabled, closest: () => (insideHidden ? {} : null) }) as unknown as HTMLButtonElement;
|
||||
|
||||
assert.equal(isMediaTimingReviewButtonAvailable(button(false, false)), true);
|
||||
assert.equal(isMediaTimingReviewButtonAvailable(button(true, false)), false);
|
||||
assert.equal(isMediaTimingReviewButtonAvailable(button(false, true)), false);
|
||||
});
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
MediaTimingReviewPreviewPosition,
|
||||
} from '../../types/anki';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import type {
|
||||
MediaTimingReviewControllerAction,
|
||||
MediaTimingReviewStep,
|
||||
} from '../handlers/media-timing-review-controller.js';
|
||||
import type { RendererDom } from '../utils/dom';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
||||
@@ -24,6 +29,43 @@ const MINIMUM_TRAILING_TRIM_SECONDS = 0.1;
|
||||
/** Allow buffering and output-device delays without cutting off an advancing preview. */
|
||||
const PREVIEW_STALL_TIMEOUT_MS = 15_000;
|
||||
|
||||
type ButtonDomKey = {
|
||||
[Key in keyof RendererDom]: RendererDom[Key] extends HTMLButtonElement ? Key : never;
|
||||
}[keyof RendererDom];
|
||||
|
||||
/** The on-screen button each controller action presses. */
|
||||
export const MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS: Record<
|
||||
MediaTimingReviewControllerAction,
|
||||
ButtonDomKey
|
||||
> = {
|
||||
confirm: 'mediaTimingReviewConfirm',
|
||||
cancel: 'mediaTimingReviewCancel',
|
||||
play: 'mediaTimingReviewPlay',
|
||||
resetTiming: 'mediaTimingReviewReset',
|
||||
showEarlier: 'mediaTimingReviewShowEarlier',
|
||||
showLater: 'mediaTimingReviewShowLater',
|
||||
startBack: 'mediaTimingReviewStartBack',
|
||||
startForward: 'mediaTimingReviewStartForward',
|
||||
endBack: 'mediaTimingReviewEndBack',
|
||||
endForward: 'mediaTimingReviewEndForward',
|
||||
previousLineAdd: 'mediaTimingReviewPrevAdd',
|
||||
previousLineRemove: 'mediaTimingReviewPrevRemove',
|
||||
nextLineAdd: 'mediaTimingReviewNextAdd',
|
||||
nextLineRemove: 'mediaTimingReviewNextRemove',
|
||||
framePrevious: 'mediaTimingReviewFramePrevious',
|
||||
frameNext: 'mediaTimingReviewFrameNext',
|
||||
frameReset: 'mediaTimingReviewFrameReset',
|
||||
keepEditing: 'mediaTimingReviewCancelBack',
|
||||
useOriginal: 'mediaTimingReviewUseOriginal',
|
||||
skipMedia: 'mediaTimingReviewSkipMedia',
|
||||
discard: 'mediaTimingReviewDiscard',
|
||||
};
|
||||
|
||||
/** A controller press only counts when a mouse click could have reached the button. */
|
||||
export function isMediaTimingReviewButtonAvailable(button: HTMLButtonElement): boolean {
|
||||
return !button.disabled && button.closest('.hidden') === null;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
@@ -962,6 +1004,44 @@ export function createMediaTimingReviewModal(
|
||||
return false;
|
||||
}
|
||||
|
||||
function getControllerStep(): MediaTimingReviewStep {
|
||||
return ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') ? 'editor' : 'cancel';
|
||||
}
|
||||
|
||||
/** Presses the on-screen button for a controller action, so every guard it has applies. */
|
||||
function handleControllerAction(action: MediaTimingReviewControllerAction): boolean {
|
||||
if (!ctx.state.mediaTimingReviewModalOpen) return false;
|
||||
const button = ctx.dom[MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS[action]];
|
||||
if (!isMediaTimingReviewButtonAvailable(button)) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hints live in a data attribute drawn by CSS, so they survive code that rewrites a
|
||||
* button's text (Skip media / Discard labels change with the card). Unbound actions
|
||||
* show no tag. Only changed attributes are written, since this runs every frame.
|
||||
*/
|
||||
function setControllerHints(
|
||||
hints: Record<MediaTimingReviewControllerAction, string | null>,
|
||||
): void {
|
||||
for (const [action, domKey] of Object.entries(MEDIA_TIMING_REVIEW_CONTROLLER_BUTTONS) as Array<
|
||||
[MediaTimingReviewControllerAction, ButtonDomKey]
|
||||
>) {
|
||||
const button = ctx.dom[domKey];
|
||||
const hint = hints[action];
|
||||
if (hint === null || hint === undefined) {
|
||||
if (button.dataset.controllerHint !== undefined) delete button.dataset.controllerHint;
|
||||
} else if (button.dataset.controllerHint !== hint) {
|
||||
button.dataset.controllerHint = hint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setControllerHintsVisible(visible: boolean): void {
|
||||
ctx.dom.mediaTimingReviewModal.classList.toggle('controller-hints-visible', visible);
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.mediaTimingReviewFrameSlider.addEventListener('input', () => {
|
||||
if (!resolveInFlight) framePicker.choose(Number(ctx.dom.mediaTimingReviewFrameSlider.value));
|
||||
@@ -1050,5 +1130,9 @@ export function createMediaTimingReviewModal(
|
||||
requestCancel,
|
||||
handleMediaTimingReviewKeydown,
|
||||
wireDomEvents,
|
||||
getControllerStep,
|
||||
handleControllerAction,
|
||||
setControllerHints,
|
||||
setControllerHintsVisible,
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,20 @@ import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
||||
import {
|
||||
createMediaTimingReviewControllerLayout,
|
||||
formatMediaTimingReviewControllerHints,
|
||||
} from './handlers/media-timing-review-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from './controller-profile-config.js';
|
||||
import { getDefaultControllerReviewBindings } from '../shared/controller-review';
|
||||
import {
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
getDefaultControllerFieldGroupingBindings,
|
||||
} from '../shared/controller-field-grouping';
|
||||
import {
|
||||
createControllerComboScreenLayout,
|
||||
formatControllerScreenHints,
|
||||
} from './handlers/controller-screen-layout.js';
|
||||
import { createPositioningController } from './positioning.js';
|
||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||
@@ -311,6 +325,20 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const mediaTimingReviewControllerLayout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => mediaTimingReviewModal.getControllerStep(),
|
||||
dispatch: (action) => {
|
||||
mediaTimingReviewModal.handleControllerAction(action);
|
||||
},
|
||||
});
|
||||
const fieldGroupingControllerLayout = createControllerComboScreenLayout({
|
||||
screen: CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
getBindings: (config) => config.fieldGroupingBindings,
|
||||
getStep: () => kikuModal.getControllerStep(),
|
||||
dispatch: (action) => {
|
||||
kikuModal.handleControllerAction(action);
|
||||
},
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
@@ -389,16 +417,54 @@ function applyControllerSnapshot(snapshot: {
|
||||
rawAxes: number[];
|
||||
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
||||
}): void {
|
||||
controllerStatusIndicator.update({
|
||||
connectedGamepads: snapshot.connectedGamepads,
|
||||
activeGamepadId: snapshot.activeGamepadId,
|
||||
});
|
||||
// Modal windows are fresh documents that only see the pad after a press; announcing
|
||||
// that as "Controller detected" on every dialog reads like a reconnection.
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
controllerStatusIndicator.update({
|
||||
connectedGamepads: snapshot.connectedGamepads,
|
||||
activeGamepadId: snapshot.activeGamepadId,
|
||||
});
|
||||
}
|
||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||
controllerSelectModal.updateDevices();
|
||||
controllerDebugModal.updateSnapshot();
|
||||
// Shown whenever controller support is on: a fresh dialog window cannot see the pad
|
||||
// until its first press, so "connected" would hide the hints exactly when needed.
|
||||
const controllerConfig = ctx.state.controllerConfig;
|
||||
const hintsVisible = controllerConfig?.enabled === true;
|
||||
mediaTimingReviewModal.setControllerHintsVisible(hintsVisible);
|
||||
kikuModal.setControllerHintsVisible(hintsVisible);
|
||||
if (
|
||||
!controllerConfig ||
|
||||
!hintsVisible ||
|
||||
(!ctx.state.mediaTimingReviewModalOpen && !ctx.state.kikuModalOpen)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Before the dialog window has seen the pad, use the saved controller's bindings.
|
||||
const gamepadId = snapshot.activeGamepadId ?? controllerConfig.preferredGamepadId;
|
||||
const mapping =
|
||||
snapshot.connectedGamepads.find((device) => device.id === snapshot.activeGamepadId)?.mapping ??
|
||||
'standard';
|
||||
const padConfig = resolveControllerConfigForGamepad(controllerConfig, gamepadId);
|
||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
||||
mediaTimingReviewModal.setControllerHints(
|
||||
formatMediaTimingReviewControllerHints(padConfig, mapping),
|
||||
);
|
||||
}
|
||||
if (ctx.state.kikuModalOpen) {
|
||||
kikuModal.setControllerHints(
|
||||
formatControllerScreenHints(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
padConfig.fieldGroupingBindings,
|
||||
padConfig,
|
||||
mapping,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function showControllerDisabledNotice(): void {
|
||||
@@ -436,6 +502,7 @@ function startControllerPolling(): void {
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
faceButtonLabels: 'xbox',
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
@@ -469,6 +536,8 @@ function startControllerPolling(): void {
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
reviewBindings: getDefaultControllerReviewBindings(),
|
||||
fieldGroupingBindings: getDefaultControllerFieldGroupingBindings(),
|
||||
profiles: {},
|
||||
},
|
||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
||||
@@ -519,6 +588,13 @@ function startControllerPolling(): void {
|
||||
onState: (snapshot) => {
|
||||
applyControllerSnapshot(snapshot);
|
||||
},
|
||||
getScreenLayout: () =>
|
||||
ctx.state.mediaTimingReviewModalOpen
|
||||
? mediaTimingReviewControllerLayout
|
||||
: ctx.state.kikuModalOpen
|
||||
? fieldGroupingControllerLayout
|
||||
: null,
|
||||
getSuspended: () => ctx.state.controllerSuspendedByModalWindow,
|
||||
});
|
||||
|
||||
const poll = (now: number): void => {
|
||||
@@ -721,6 +797,10 @@ function registerKeyboardCommandHandlers(): void {
|
||||
});
|
||||
});
|
||||
|
||||
window.electronAPI.onControllerSuspendedChanged((suspended: boolean) => {
|
||||
ctx.state.controllerSuspendedByModalWindow = suspended;
|
||||
});
|
||||
|
||||
window.electronAPI.onKeyboardModeToggleRequested(() => {
|
||||
runGuarded('keyboard-mode-toggle:requested', () => {
|
||||
keyboardHandlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
@@ -67,6 +67,8 @@ export type RendererState = {
|
||||
youtubePickerStatus: string;
|
||||
|
||||
mediaTimingReviewModalOpen: boolean;
|
||||
/** True while a modal in the separate modal window owns the controller. */
|
||||
controllerSuspendedByModalWindow: boolean;
|
||||
|
||||
kikuModalOpen: boolean;
|
||||
kikuSelectedCard: 1 | 2;
|
||||
@@ -207,6 +209,7 @@ export function createRendererState(): RendererState {
|
||||
youtubePickerStatus: '',
|
||||
|
||||
mediaTimingReviewModalOpen: false,
|
||||
controllerSuspendedByModalWindow: false,
|
||||
|
||||
kikuModalOpen: false,
|
||||
kikuSelectedCard: 1,
|
||||
|
||||
@@ -2072,6 +2072,34 @@ body:focus-visible,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Controller hints: shown beside each control while controller support is on. */
|
||||
.media-timing-review-modal.controller-hints-visible button[data-controller-hint]::after,
|
||||
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
|
||||
content: attr(data-controller-hint);
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 5px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-subtext0);
|
||||
font-family: inherit;
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.media-timing-review-modal.controller-hints-visible .media-timing-review-line-stepper button {
|
||||
width: auto;
|
||||
min-width: 24px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.media-timing-review-cancel-step {
|
||||
min-height: 300px;
|
||||
padding: 44px 38px 34px;
|
||||
@@ -2951,9 +2979,23 @@ iframe[id^='yomitan-popup'],
|
||||
|
||||
.kiku-preview-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* "Scroll ▲ ▼" tag in the preview header, only while controller hints show. */
|
||||
.kiku-preview-scroll-hint {
|
||||
display: none;
|
||||
margin-left: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
#kikuFieldGroupingModal.controller-hints-visible .kiku-preview-scroll-hint[data-controller-hint] {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.kiku-preview-toggle button {
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
@@ -3476,6 +3518,40 @@ iframe[id^='yomitan-popup'],
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.controller-config-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.controller-config-group-action {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.controller-config-subgroup {
|
||||
padding: 10px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.controller-config-warning {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.controller-config-row.has-warning .controller-config-badge {
|
||||
border-color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.controller-config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -103,6 +103,11 @@ export type RendererDom = {
|
||||
kikuCard2Sentence: HTMLDivElement;
|
||||
kikuCard1Meta: HTMLDivElement;
|
||||
kikuCard2Meta: HTMLDivElement;
|
||||
kikuCard1Label: HTMLDivElement;
|
||||
kikuCard2Label: HTMLDivElement;
|
||||
kikuDeleteToggle: HTMLLabelElement;
|
||||
kikuPreviewToggle: HTMLDivElement;
|
||||
kikuPreviewScrollHint: HTMLSpanElement;
|
||||
kikuConfirmButton: HTMLButtonElement;
|
||||
kikuCancelButton: HTMLButtonElement;
|
||||
kikuDeleteDuplicateCheckbox: HTMLInputElement;
|
||||
@@ -359,6 +364,11 @@ export function resolveRendererDom(): RendererDom {
|
||||
kikuCard2Sentence: getRequiredElement<HTMLDivElement>('kikuCard2Sentence'),
|
||||
kikuCard1Meta: getRequiredElement<HTMLDivElement>('kikuCard1Meta'),
|
||||
kikuCard2Meta: getRequiredElement<HTMLDivElement>('kikuCard2Meta'),
|
||||
kikuCard1Label: getRequiredElement<HTMLDivElement>('kikuCard1Label'),
|
||||
kikuCard2Label: getRequiredElement<HTMLDivElement>('kikuCard2Label'),
|
||||
kikuDeleteToggle: getRequiredElement<HTMLLabelElement>('kikuDeleteToggle'),
|
||||
kikuPreviewToggle: getRequiredElement<HTMLDivElement>('kikuPreviewToggle'),
|
||||
kikuPreviewScrollHint: getRequiredElement<HTMLSpanElement>('kikuPreviewScrollHint'),
|
||||
kikuConfirmButton: getRequiredElement<HTMLButtonElement>('kikuConfirmButton'),
|
||||
kikuCancelButton: getRequiredElement<HTMLButtonElement>('kikuCancelButton'),
|
||||
kikuDeleteDuplicateCheckbox: getRequiredElement<HTMLInputElement>('kikuDeleteDuplicate'),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
ControllerFieldGroupingAction,
|
||||
ResolvedControllerFieldGroupingBindings,
|
||||
} from '../types/runtime';
|
||||
import {
|
||||
combo,
|
||||
dpad,
|
||||
face,
|
||||
type ControllerScreenActionDefinition,
|
||||
type ControllerScreenDefinition,
|
||||
} from './controller-review';
|
||||
|
||||
/** The duplicate-card dialog: pick the card to keep, then the final merge preview. */
|
||||
export type ControllerFieldGroupingStep = 'select' | 'preview';
|
||||
|
||||
export type ControllerFieldGroupingActionDefinition = ControllerScreenActionDefinition<
|
||||
ControllerFieldGroupingAction,
|
||||
ControllerFieldGroupingStep
|
||||
>;
|
||||
|
||||
/** Every field grouping action, in the order Alt+C lists them. */
|
||||
export const CONTROLLER_FIELD_GROUPING_ACTIONS: readonly ControllerFieldGroupingActionDefinition[] =
|
||||
[
|
||||
{ id: 'selectOriginal', label: 'Original card', step: 'select', repeat: false },
|
||||
{ id: 'selectNew', label: 'New card', step: 'select', repeat: false },
|
||||
{
|
||||
id: 'toggleDeleteDuplicate',
|
||||
label: 'Delete duplicate on/off',
|
||||
step: 'select',
|
||||
repeat: false,
|
||||
},
|
||||
{ id: 'continue', label: 'Continue', step: 'select', repeat: false },
|
||||
{ id: 'cancel', label: 'Cancel', step: 'select', repeat: false },
|
||||
{ id: 'confirmMerge', label: 'Confirm merge', step: 'preview', repeat: false },
|
||||
{ id: 'back', label: 'Back', step: 'preview', repeat: false },
|
||||
{ id: 'cancelMerge', label: 'Cancel', step: 'preview', repeat: false },
|
||||
{ id: 'toggleView', label: 'Compact / Full', step: 'preview', repeat: false },
|
||||
{ id: 'scrollUp', label: 'Scroll up', step: 'preview', repeat: true },
|
||||
{ id: 'scrollDown', label: 'Scroll down', step: 'preview', repeat: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default layout: D-pad picks the card, A goes forward, B goes back (B on the preview
|
||||
* returns to the card choice, B there cancels). Cancel on the preview is unbound, so a
|
||||
* double B press is the way out unless the user binds it.
|
||||
*/
|
||||
const DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS: ResolvedControllerFieldGroupingBindings = {
|
||||
selectOriginal: combo(dpad('left')),
|
||||
selectNew: combo(dpad('right')),
|
||||
toggleDeleteDuplicate: combo(face('x')),
|
||||
continue: combo(face('a')),
|
||||
cancel: combo(face('b')),
|
||||
confirmMerge: combo(face('a')),
|
||||
back: combo(face('b')),
|
||||
cancelMerge: { kind: 'none' },
|
||||
toggleView: combo(face('y')),
|
||||
scrollUp: combo(dpad('up')),
|
||||
scrollDown: combo(dpad('down')),
|
||||
};
|
||||
|
||||
export const CONTROLLER_FIELD_GROUPING_SCREEN: ControllerScreenDefinition<
|
||||
ControllerFieldGroupingAction,
|
||||
ControllerFieldGroupingStep
|
||||
> = {
|
||||
id: 'fieldGrouping',
|
||||
title: 'Duplicate card (field grouping)',
|
||||
configKey: 'fieldGroupingBindings',
|
||||
describe: 'Duplicate card dialog',
|
||||
steps: [
|
||||
{ id: 'select', label: 'Choosing the card' },
|
||||
{ id: 'preview', label: 'Merge preview' },
|
||||
],
|
||||
actions: CONTROLLER_FIELD_GROUPING_ACTIONS,
|
||||
defaults: DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS,
|
||||
};
|
||||
|
||||
export function getDefaultControllerFieldGroupingBindings(): ResolvedControllerFieldGroupingBindings {
|
||||
return structuredClone(DEFAULT_CONTROLLER_FIELD_GROUPING_BINDINGS);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../config/definitions/defaults-core';
|
||||
import {
|
||||
controllerReviewInputSourceKey,
|
||||
formatControllerReviewBinding,
|
||||
formatControllerReviewInput,
|
||||
parseControllerReviewBinding,
|
||||
resolveControllerReviewInput,
|
||||
type ControllerReviewInputContext,
|
||||
} from './controller-review';
|
||||
import { parseControllerConfigUpdate } from './ipc/validators';
|
||||
|
||||
const buttonIndices = CORE_DEFAULT_CONFIG.controller.buttonIndices;
|
||||
const standardXbox: ControllerReviewInputContext = {
|
||||
mapping: 'standard',
|
||||
faceButtonLabels: 'xbox',
|
||||
buttonIndices,
|
||||
};
|
||||
|
||||
test('review bindings parse none, single presses and combos; anything else is rejected', () => {
|
||||
assert.deepEqual(parseControllerReviewBinding({ kind: 'none' }), { kind: 'none' });
|
||||
assert.deepEqual(parseControllerReviewBinding({ press: { kind: 'face', letter: 'a' } }), {
|
||||
kind: 'combo',
|
||||
press: { kind: 'face', letter: 'a' },
|
||||
hold: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseControllerReviewBinding({
|
||||
kind: 'combo',
|
||||
press: { kind: 'dpad', direction: 'up' },
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
}),
|
||||
{
|
||||
kind: 'combo',
|
||||
press: { kind: 'dpad', direction: 'up' },
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
);
|
||||
for (const bad of [
|
||||
null,
|
||||
{},
|
||||
{ kind: 'button', buttonIndex: 1 },
|
||||
{ press: { kind: 'face', letter: 'z' } },
|
||||
{ press: { kind: 'button', buttonIndex: -1 } },
|
||||
{ press: { kind: 'axis', axisIndex: 2 } },
|
||||
{ press: { kind: 'dpad', direction: 'left' }, hold: { kind: 'dpad' } },
|
||||
]) {
|
||||
assert.equal(parseControllerReviewBinding(bad), null, JSON.stringify(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('the same physical input resolves to the same key across layouts', () => {
|
||||
const key = (input: Parameters<typeof resolveControllerReviewInput>[0], context = standardXbox) =>
|
||||
controllerReviewInputSourceKey(resolveControllerReviewInput(input, context));
|
||||
|
||||
// Letters follow faceButtonLabels.
|
||||
assert.equal(key({ kind: 'face', letter: 'a' }), 'button:0');
|
||||
assert.equal(
|
||||
key({ kind: 'face', letter: 'a' }, { ...standardXbox, faceButtonLabels: 'nintendo' }),
|
||||
'button:1',
|
||||
);
|
||||
// A learned standard D-pad button and the default D-pad entry are one input.
|
||||
assert.equal(key({ kind: 'button', buttonIndex: 14 }), 'dpad:left');
|
||||
assert.equal(key({ kind: 'dpad', direction: 'left' }), 'dpad:left');
|
||||
// Non-standard pads report the D-pad on axes 6 and 7.
|
||||
const raw = { ...standardXbox, mapping: '' };
|
||||
assert.equal(key({ kind: 'axis', axisIndex: 7, direction: 'positive' }, raw), 'dpad:down');
|
||||
assert.equal(key({ kind: 'button', buttonIndex: 14 }, raw), 'button:14');
|
||||
assert.equal(key({ kind: 'axis', axisIndex: 2, direction: 'positive' }, raw), 'axis:2:positive');
|
||||
});
|
||||
|
||||
test('review inputs are named as printed and by layout', () => {
|
||||
const name = (input: Parameters<typeof formatControllerReviewInput>[0], context = standardXbox) =>
|
||||
formatControllerReviewInput(input, context, { dpadPrefix: true });
|
||||
const nintendo = { ...standardXbox, faceButtonLabels: 'nintendo' as const };
|
||||
const raw = { ...standardXbox, mapping: '' };
|
||||
|
||||
assert.equal(name({ kind: 'face', letter: 'x' }, nintendo), 'X');
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 0 }, nintendo), 'B');
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 6 }), 'L2');
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 6 }, raw), 'Back');
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 10 }), 'L3');
|
||||
assert.equal(name({ kind: 'button', buttonIndex: 15 }), 'D-pad ▶');
|
||||
assert.equal(name({ kind: 'axis', axisIndex: 1, direction: 'negative' }), 'LS ▲');
|
||||
assert.equal(name({ kind: 'axis', axisIndex: 5, direction: 'positive' }, raw), 'R2');
|
||||
assert.equal(name({ kind: 'axis', axisIndex: 9, direction: 'positive' }), 'Axis 9 +');
|
||||
assert.equal(
|
||||
formatControllerReviewBinding(
|
||||
{
|
||||
kind: 'combo',
|
||||
press: { kind: 'dpad', direction: 'up' },
|
||||
hold: { kind: 'button', buttonIndex: 5 },
|
||||
},
|
||||
standardXbox,
|
||||
),
|
||||
'R1 + ▲',
|
||||
);
|
||||
});
|
||||
|
||||
test('controller config updates from Alt+C accept review bindings and reject bad ones', () => {
|
||||
// The validator builds prototype-less objects; compare plain copies.
|
||||
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
assert.deepEqual(
|
||||
plain(
|
||||
parseControllerConfigUpdate({
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
reviewBindings: {
|
||||
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
reviewBindings: {
|
||||
confirm: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(parseControllerConfigUpdate({ reviewBindings: { nope: { kind: 'none' } } }), null);
|
||||
assert.equal(
|
||||
parseControllerConfigUpdate({ reviewBindings: { confirm: { press: { kind: 'face' } } } }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('Alt+C updates accept field grouping bindings, checked against that dialog', () => {
|
||||
const plain = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
assert.deepEqual(
|
||||
plain(
|
||||
parseControllerConfigUpdate({
|
||||
profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } },
|
||||
}),
|
||||
),
|
||||
{ profiles: { 'pad-1': { fieldGroupingBindings: { back: { kind: 'none' } } } } },
|
||||
);
|
||||
// Each dialog only accepts its own actions.
|
||||
assert.equal(
|
||||
parseControllerConfigUpdate({ fieldGroupingBindings: { confirm: { kind: 'none' } } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseControllerConfigUpdate({ reviewBindings: { back: { kind: 'none' } } }), null);
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import type {
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadDirection,
|
||||
ControllerFaceButtonLabels,
|
||||
ControllerFaceLetter,
|
||||
ControllerReviewAction,
|
||||
ControllerReviewInput,
|
||||
ResolvedControllerReviewBinding,
|
||||
ResolvedControllerReviewBindings,
|
||||
} from '../types/runtime';
|
||||
|
||||
/**
|
||||
* A dialog with its own controller layout (media timing review, field grouping). Each
|
||||
* action presses one on-screen control on one step of the dialog; bindings are
|
||||
* press-or-combo, remappable in Alt+C, and stored under `controller.<configKey>`.
|
||||
*/
|
||||
export type ControllerScreenDefinition<A extends string = string, S extends string = string> = {
|
||||
id: 'review' | 'fieldGrouping';
|
||||
/** Alt+C group title. */
|
||||
title: string;
|
||||
/** Config key on `controller` and on each profile. */
|
||||
configKey: 'reviewBindings' | 'fieldGroupingBindings';
|
||||
/** Prefix for generated config descriptions, e.g. `Review dialog`. */
|
||||
describe: string;
|
||||
steps: readonly { id: S; label: string }[];
|
||||
actions: readonly ControllerScreenActionDefinition<A, S>[];
|
||||
defaults: Readonly<Record<A, ResolvedControllerReviewBinding>>;
|
||||
};
|
||||
|
||||
export type ControllerScreenActionDefinition<
|
||||
A extends string = string,
|
||||
S extends string = string,
|
||||
> = {
|
||||
id: A;
|
||||
label: string;
|
||||
step: S;
|
||||
/** Repeats while held, like a held arrow key. */
|
||||
repeat: boolean;
|
||||
};
|
||||
|
||||
export type ControllerScreenBindings<A extends string = string> = Record<
|
||||
A,
|
||||
ResolvedControllerReviewBinding
|
||||
>;
|
||||
|
||||
/** The review shows either the timing editor or the "Stop reviewing?" step. */
|
||||
export type ControllerReviewStep = 'editor' | 'cancel';
|
||||
|
||||
export type ControllerReviewActionDefinition = ControllerScreenActionDefinition<
|
||||
ControllerReviewAction,
|
||||
ControllerReviewStep
|
||||
>;
|
||||
|
||||
/** Every review action, in the order Alt+C lists them. */
|
||||
export const CONTROLLER_REVIEW_ACTIONS: readonly ControllerReviewActionDefinition[] = [
|
||||
{ id: 'confirm', label: 'Use this timing', step: 'editor', repeat: false },
|
||||
{ id: 'cancel', label: 'Cancel', step: 'editor', repeat: false },
|
||||
{ id: 'play', label: 'Play selection', step: 'editor', repeat: false },
|
||||
{ id: 'resetTiming', label: 'Reset timing', step: 'editor', repeat: false },
|
||||
{ id: 'showEarlier', label: 'Earlier −2s', step: 'editor', repeat: true },
|
||||
{ id: 'showLater', label: 'Later +2s', step: 'editor', repeat: true },
|
||||
{ id: 'framePrevious', label: 'Previous screenshot frame', step: 'editor', repeat: true },
|
||||
{ id: 'frameNext', label: 'Next screenshot frame', step: 'editor', repeat: true },
|
||||
{ id: 'frameReset', label: 'Reset screenshot frame', step: 'editor', repeat: false },
|
||||
{ id: 'startBack', label: 'Start −0.1s', step: 'editor', repeat: true },
|
||||
{ id: 'startForward', label: 'Start +0.1s', step: 'editor', repeat: true },
|
||||
{ id: 'previousLineAdd', label: 'Add previous line', step: 'editor', repeat: true },
|
||||
{ id: 'previousLineRemove', label: 'Remove previous line', step: 'editor', repeat: true },
|
||||
{ id: 'endBack', label: 'End −0.1s', step: 'editor', repeat: true },
|
||||
{ id: 'endForward', label: 'End +0.1s', step: 'editor', repeat: true },
|
||||
{ id: 'nextLineAdd', label: 'Add next line', step: 'editor', repeat: true },
|
||||
{ id: 'nextLineRemove', label: 'Remove next line', step: 'editor', repeat: true },
|
||||
{ id: 'useOriginal', label: 'Use original timing', step: 'cancel', repeat: false },
|
||||
{ id: 'keepEditing', label: 'Keep editing', step: 'cancel', repeat: false },
|
||||
{ id: 'skipMedia', label: 'Without media', step: 'cancel', repeat: false },
|
||||
{ id: 'discard', label: 'Discard card', step: 'cancel', repeat: false },
|
||||
];
|
||||
|
||||
const ACTION_BY_ID = new Map(CONTROLLER_REVIEW_ACTIONS.map((action) => [action.id, action]));
|
||||
|
||||
export function getControllerReviewAction(
|
||||
id: ControllerReviewAction,
|
||||
): ControllerReviewActionDefinition {
|
||||
return ACTION_BY_ID.get(id)!;
|
||||
}
|
||||
|
||||
export function isControllerReviewAction(value: string): value is ControllerReviewAction {
|
||||
return ACTION_BY_ID.has(value as ControllerReviewAction);
|
||||
}
|
||||
|
||||
export function getControllerScreenAction<A extends string, S extends string>(
|
||||
screen: ControllerScreenDefinition<A, S>,
|
||||
id: A,
|
||||
): ControllerScreenActionDefinition<A, S> {
|
||||
return screen.actions.find((action) => action.id === id)!;
|
||||
}
|
||||
|
||||
export function isControllerScreenAction<A extends string>(
|
||||
screen: ControllerScreenDefinition<A>,
|
||||
value: string,
|
||||
): value is A {
|
||||
return screen.actions.some((action) => action.id === value);
|
||||
}
|
||||
|
||||
export function getDefaultControllerScreenBindings<A extends string>(
|
||||
screen: ControllerScreenDefinition<A>,
|
||||
): ControllerScreenBindings<A> {
|
||||
return structuredClone(screen.defaults) as ControllerScreenBindings<A>;
|
||||
}
|
||||
|
||||
export const face = (letter: ControllerFaceLetter): ControllerReviewInput => ({
|
||||
kind: 'face',
|
||||
letter,
|
||||
});
|
||||
export const dpad = (direction: ControllerDpadDirection): ControllerReviewInput => ({
|
||||
kind: 'dpad',
|
||||
direction,
|
||||
});
|
||||
const LEFT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 4 };
|
||||
const RIGHT_SHOULDER: ControllerReviewInput = { kind: 'button', buttonIndex: 5 };
|
||||
export const combo = (
|
||||
press: ControllerReviewInput,
|
||||
hold: ControllerReviewInput | null = null,
|
||||
): ResolvedControllerReviewBinding => ({ kind: 'combo', press, hold });
|
||||
|
||||
/**
|
||||
* Default layout. L1 holds everything about the clip start (and lines before it), R1
|
||||
* everything about the clip end (and lines after it). Face buttons follow the printed
|
||||
* letter and D-pad entries accept any D-pad report, so these work on every layout.
|
||||
*/
|
||||
const DEFAULT_CONTROLLER_REVIEW_BINDINGS: ResolvedControllerReviewBindings = {
|
||||
confirm: combo(face('a')),
|
||||
cancel: combo(face('b')),
|
||||
play: combo(face('x')),
|
||||
resetTiming: combo(face('y')),
|
||||
showEarlier: combo(dpad('left')),
|
||||
showLater: combo(dpad('right')),
|
||||
framePrevious: combo(dpad('up')),
|
||||
frameNext: combo(dpad('down')),
|
||||
frameReset: combo(RIGHT_SHOULDER, LEFT_SHOULDER),
|
||||
startBack: combo(dpad('left'), LEFT_SHOULDER),
|
||||
startForward: combo(dpad('right'), LEFT_SHOULDER),
|
||||
previousLineAdd: combo(dpad('up'), LEFT_SHOULDER),
|
||||
previousLineRemove: combo(dpad('down'), LEFT_SHOULDER),
|
||||
endBack: combo(dpad('left'), RIGHT_SHOULDER),
|
||||
endForward: combo(dpad('right'), RIGHT_SHOULDER),
|
||||
nextLineAdd: combo(dpad('up'), RIGHT_SHOULDER),
|
||||
nextLineRemove: combo(dpad('down'), RIGHT_SHOULDER),
|
||||
useOriginal: combo(face('a')),
|
||||
keepEditing: combo(face('b')),
|
||||
skipMedia: combo(face('x')),
|
||||
discard: combo(face('y')),
|
||||
};
|
||||
|
||||
export const CONTROLLER_REVIEW_SCREEN: ControllerScreenDefinition<
|
||||
ControllerReviewAction,
|
||||
ControllerReviewStep
|
||||
> = {
|
||||
id: 'review',
|
||||
title: 'Review media timing',
|
||||
configKey: 'reviewBindings',
|
||||
describe: 'Review dialog',
|
||||
steps: [
|
||||
{ id: 'editor', label: 'While editing' },
|
||||
{ id: 'cancel', label: 'After Cancel' },
|
||||
],
|
||||
actions: CONTROLLER_REVIEW_ACTIONS,
|
||||
defaults: DEFAULT_CONTROLLER_REVIEW_BINDINGS,
|
||||
};
|
||||
|
||||
export function getDefaultControllerReviewBindings(): ResolvedControllerReviewBindings {
|
||||
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS);
|
||||
}
|
||||
|
||||
export function getDefaultControllerReviewBinding(
|
||||
action: ControllerReviewAction,
|
||||
): ResolvedControllerReviewBinding {
|
||||
return structuredClone(DEFAULT_CONTROLLER_REVIEW_BINDINGS[action]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isIndex(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function parseControllerReviewInput(value: unknown): ControllerReviewInput | null {
|
||||
if (!isRecord(value)) return null;
|
||||
switch (value.kind) {
|
||||
case 'button':
|
||||
return isIndex(value.buttonIndex) ? { kind: 'button', buttonIndex: value.buttonIndex } : null;
|
||||
case 'axis':
|
||||
return isIndex(value.axisIndex) &&
|
||||
(value.direction === 'negative' || value.direction === 'positive')
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
case 'face':
|
||||
return value.letter === 'a' ||
|
||||
value.letter === 'b' ||
|
||||
value.letter === 'x' ||
|
||||
value.letter === 'y'
|
||||
? { kind: 'face', letter: value.letter }
|
||||
: null;
|
||||
case 'dpad':
|
||||
return value.direction === 'left' ||
|
||||
value.direction === 'right' ||
|
||||
value.direction === 'up' ||
|
||||
value.direction === 'down'
|
||||
? { kind: 'dpad', direction: value.direction }
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `{ "kind": "none" }` or `{ "press": …, "hold"?: … }` (`kind: "combo"` optional). */
|
||||
export function parseControllerReviewBinding(
|
||||
value: unknown,
|
||||
): ResolvedControllerReviewBinding | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.kind === 'none') return { kind: 'none' };
|
||||
if (value.kind !== undefined && value.kind !== 'combo') return null;
|
||||
const press = parseControllerReviewInput(value.press);
|
||||
if (!press) return null;
|
||||
if (value.hold === undefined || value.hold === null) return combo(press);
|
||||
const hold = parseControllerReviewInput(value.hold);
|
||||
return hold ? combo(press, hold) : null;
|
||||
}
|
||||
|
||||
/** What a review input needs to know about the controller it runs on. */
|
||||
export type ControllerReviewInputContext = {
|
||||
/** Gamepad API `mapping` ("standard" or ""); unknown before the pad is seen. */
|
||||
mapping: string | null | undefined;
|
||||
faceButtonLabels: ControllerFaceButtonLabels;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
};
|
||||
|
||||
/** A physical input after resolving letters and D-pad reports; equal sources are the same input. */
|
||||
export type ControllerReviewInputSource =
|
||||
| { kind: 'button'; buttonIndex: number }
|
||||
| { kind: 'axis'; axisIndex: number; direction: ControllerAxisDirection }
|
||||
| { kind: 'dpad'; direction: ControllerDpadDirection };
|
||||
|
||||
type FacePosition = 'south' | 'east' | 'west' | 'north';
|
||||
|
||||
/** Xbox (and PlayStation) pads put A at the bottom; Nintendo-style pads put it on the right. */
|
||||
const FACE_POSITIONS: Record<
|
||||
ControllerFaceButtonLabels,
|
||||
Record<ControllerFaceLetter, FacePosition>
|
||||
> = {
|
||||
xbox: { a: 'south', b: 'east', x: 'west', y: 'north' },
|
||||
nintendo: { a: 'east', b: 'south', x: 'north', y: 'west' },
|
||||
};
|
||||
|
||||
const POSITION_INDEX_KEYS = {
|
||||
south: 'buttonSouth',
|
||||
east: 'buttonEast',
|
||||
west: 'buttonWest',
|
||||
north: 'buttonNorth',
|
||||
} as const satisfies Record<FacePosition, keyof ControllerButtonIndicesConfig>;
|
||||
|
||||
/** Unknown (pad not seen yet) counts as standard, the layout most pads report. */
|
||||
function isStandardMapping(mapping: string | null | undefined): boolean {
|
||||
return mapping === 'standard' || mapping === null || mapping === undefined;
|
||||
}
|
||||
|
||||
const STANDARD_DPAD_BUTTONS: Record<number, ControllerDpadDirection> = {
|
||||
12: 'up',
|
||||
13: 'down',
|
||||
14: 'left',
|
||||
15: 'right',
|
||||
};
|
||||
|
||||
export function resolveControllerReviewInput(
|
||||
input: ControllerReviewInput,
|
||||
context: ControllerReviewInputContext,
|
||||
): ControllerReviewInputSource {
|
||||
if (input.kind === 'dpad') return input;
|
||||
if (input.kind === 'face') {
|
||||
const positions = FACE_POSITIONS[context.faceButtonLabels] ?? FACE_POSITIONS.xbox;
|
||||
const buttonIndex = context.buttonIndices[POSITION_INDEX_KEYS[positions[input.letter]]];
|
||||
return { kind: 'button', buttonIndex };
|
||||
}
|
||||
if (input.kind === 'button') {
|
||||
const direction = isStandardMapping(context.mapping)
|
||||
? STANDARD_DPAD_BUTTONS[input.buttonIndex]
|
||||
: undefined;
|
||||
return direction ? { kind: 'dpad', direction } : input;
|
||||
}
|
||||
// Non-standard pads usually report the D-pad as axes 6 (left/right) and 7 (up/down).
|
||||
if (!isStandardMapping(context.mapping) && (input.axisIndex === 6 || input.axisIndex === 7)) {
|
||||
const negative = input.axisIndex === 6 ? 'left' : 'up';
|
||||
const positive = input.axisIndex === 6 ? 'right' : 'down';
|
||||
return { kind: 'dpad', direction: input.direction === 'negative' ? negative : positive };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function controllerReviewInputSourceKey(source: ControllerReviewInputSource): string {
|
||||
if (source.kind === 'dpad') return `dpad:${source.direction}`;
|
||||
if (source.kind === 'button') return `button:${source.buttonIndex}`;
|
||||
return `axis:${source.axisIndex}:${source.direction}`;
|
||||
}
|
||||
|
||||
export type CompiledControllerScreenBinding<A extends string = string> = {
|
||||
action: A;
|
||||
repeat: boolean;
|
||||
press: ControllerReviewInputSource;
|
||||
pressKey: string;
|
||||
hold: ControllerReviewInputSource | null;
|
||||
holdKey: string | null;
|
||||
};
|
||||
|
||||
export type CompiledControllerReviewBinding =
|
||||
CompiledControllerScreenBinding<ControllerReviewAction>;
|
||||
|
||||
/** The bindings active on one step of a screen, resolved to physical inputs, in Alt+C order. */
|
||||
export function compileControllerScreenBindings<A extends string, S extends string>(
|
||||
screen: ControllerScreenDefinition<A, S>,
|
||||
bindings: Partial<ControllerScreenBindings<A>>,
|
||||
step: S,
|
||||
context: ControllerReviewInputContext,
|
||||
): CompiledControllerScreenBinding<A>[] {
|
||||
const compiled: CompiledControllerScreenBinding<A>[] = [];
|
||||
for (const action of screen.actions) {
|
||||
if (action.step !== step) continue;
|
||||
const binding = bindings[action.id];
|
||||
if (!binding || binding.kind !== 'combo') continue;
|
||||
const press = resolveControllerReviewInput(binding.press, context);
|
||||
const hold = binding.hold ? resolveControllerReviewInput(binding.hold, context) : null;
|
||||
compiled.push({
|
||||
action: action.id,
|
||||
repeat: action.repeat,
|
||||
press,
|
||||
pressKey: controllerReviewInputSourceKey(press),
|
||||
hold,
|
||||
holdKey: hold ? controllerReviewInputSourceKey(hold) : null,
|
||||
});
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/** The review bindings active on one step, resolved to physical inputs, in Alt+C order. */
|
||||
export function compileControllerReviewBindings(
|
||||
bindings: ResolvedControllerReviewBindings,
|
||||
step: ControllerReviewStep,
|
||||
context: ControllerReviewInputContext,
|
||||
): CompiledControllerReviewBinding[] {
|
||||
return compileControllerScreenBindings(CONTROLLER_REVIEW_SCREEN, bindings, step, context);
|
||||
}
|
||||
|
||||
const DPAD_GLYPHS: Record<ControllerDpadDirection, string> = {
|
||||
left: '◀',
|
||||
right: '▶',
|
||||
up: '▲',
|
||||
down: '▼',
|
||||
};
|
||||
|
||||
const LETTER_AT_POSITION: Record<ControllerFaceButtonLabels, Record<FacePosition, string>> = {
|
||||
xbox: { south: 'A', east: 'B', west: 'X', north: 'Y' },
|
||||
nintendo: { south: 'B', east: 'A', west: 'Y', north: 'X' },
|
||||
};
|
||||
|
||||
const FACE_POSITION_BY_INDEX: Record<number, FacePosition> = {
|
||||
0: 'south',
|
||||
1: 'east',
|
||||
2: 'west',
|
||||
3: 'north',
|
||||
};
|
||||
|
||||
const STANDARD_BUTTON_NAMES: Record<number, string> = {
|
||||
4: 'L1',
|
||||
5: 'R1',
|
||||
6: 'L2',
|
||||
7: 'R2',
|
||||
8: 'Select',
|
||||
9: 'Start',
|
||||
10: 'L3',
|
||||
11: 'R3',
|
||||
16: 'Home',
|
||||
};
|
||||
|
||||
const RAW_BUTTON_NAMES: Record<number, string> = {
|
||||
4: 'L1',
|
||||
5: 'R1',
|
||||
6: 'Back',
|
||||
7: 'Start',
|
||||
8: 'L3',
|
||||
9: 'R3',
|
||||
};
|
||||
|
||||
type AxisName = { name: string; horizontal: boolean } | { name: string; trigger: true };
|
||||
|
||||
const STANDARD_AXIS_NAMES: Record<number, AxisName> = {
|
||||
0: { name: 'LS', horizontal: true },
|
||||
1: { name: 'LS', horizontal: false },
|
||||
2: { name: 'RS', horizontal: true },
|
||||
3: { name: 'RS', horizontal: false },
|
||||
};
|
||||
|
||||
const RAW_AXIS_NAMES: Record<number, AxisName> = {
|
||||
0: { name: 'LS', horizontal: true },
|
||||
1: { name: 'LS', horizontal: false },
|
||||
2: { name: 'L2', trigger: true },
|
||||
3: { name: 'RS', horizontal: true },
|
||||
4: { name: 'RS', horizontal: false },
|
||||
5: { name: 'R2', trigger: true },
|
||||
};
|
||||
|
||||
function formatSource(
|
||||
source: ControllerReviewInputSource,
|
||||
context: ControllerReviewInputContext,
|
||||
dpadPrefix: boolean,
|
||||
): string {
|
||||
if (source.kind === 'dpad') {
|
||||
return `${dpadPrefix ? 'D-pad ' : ''}${DPAD_GLYPHS[source.direction]}`;
|
||||
}
|
||||
const standard = isStandardMapping(context.mapping);
|
||||
if (source.kind === 'button') {
|
||||
const position = FACE_POSITION_BY_INDEX[source.buttonIndex];
|
||||
if (position) {
|
||||
return (LETTER_AT_POSITION[context.faceButtonLabels] ?? LETTER_AT_POSITION.xbox)[position];
|
||||
}
|
||||
const names = standard ? STANDARD_BUTTON_NAMES : RAW_BUTTON_NAMES;
|
||||
return names[source.buttonIndex] ?? `Button ${source.buttonIndex}`;
|
||||
}
|
||||
const axis = (standard ? STANDARD_AXIS_NAMES : RAW_AXIS_NAMES)[source.axisIndex];
|
||||
const sign = source.direction === 'negative' ? '−' : '+';
|
||||
if (!axis) return `Axis ${source.axisIndex} ${sign}`;
|
||||
if ('trigger' in axis) return axis.name;
|
||||
const glyph = axis.horizontal
|
||||
? source.direction === 'negative'
|
||||
? DPAD_GLYPHS.left
|
||||
: DPAD_GLYPHS.right
|
||||
: source.direction === 'negative'
|
||||
? DPAD_GLYPHS.up
|
||||
: DPAD_GLYPHS.down;
|
||||
return `${axis.name} ${glyph}`;
|
||||
}
|
||||
|
||||
/** Short name for one input, e.g. `A`, `L1`, `◀` (or `D-pad ◀`), `LS ▲`. */
|
||||
export function formatControllerReviewInput(
|
||||
input: ControllerReviewInput,
|
||||
context: ControllerReviewInputContext,
|
||||
options: { dpadPrefix?: boolean } = {},
|
||||
): string {
|
||||
// Letters are shown as printed, whatever position they resolve to.
|
||||
if (input.kind === 'face') return input.letter.toUpperCase();
|
||||
return formatSource(
|
||||
resolveControllerReviewInput(input, context),
|
||||
context,
|
||||
options.dpadPrefix ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/** `None`, `A`, or `L1 + D-pad ▲` style label for a whole binding. */
|
||||
export function formatControllerReviewBinding(
|
||||
binding: ResolvedControllerReviewBinding,
|
||||
context: ControllerReviewInputContext,
|
||||
options: { dpadPrefix?: boolean } = {},
|
||||
): string {
|
||||
if (binding.kind === 'none') return 'None';
|
||||
const press = formatControllerReviewInput(binding.press, context, options);
|
||||
return binding.hold
|
||||
? `${formatControllerReviewInput(binding.hold, context, options)} + ${press}`
|
||||
: press;
|
||||
}
|
||||
|
||||
/**
|
||||
* Problems Alt+C should point out, per action: two actions on the same input in one step,
|
||||
* or a button used alone that other actions on that step use as their hold button.
|
||||
*/
|
||||
export function findControllerScreenBindingIssues<A extends string, S extends string>(
|
||||
screen: ControllerScreenDefinition<A, S>,
|
||||
bindings: Partial<ControllerScreenBindings<A>>,
|
||||
context: ControllerReviewInputContext,
|
||||
): Partial<Record<A, string>> {
|
||||
const issues: Partial<Record<A, string>> = {};
|
||||
const label = (id: A) => getControllerScreenAction(screen, id).label;
|
||||
for (const step of screen.steps) {
|
||||
const compiled = compileControllerScreenBindings(screen, bindings, step.id, context);
|
||||
const holdKeys = new Map<string, ControllerReviewInputSource>();
|
||||
for (const entry of compiled) {
|
||||
if (entry.hold && entry.holdKey) holdKeys.set(entry.holdKey, entry.hold);
|
||||
}
|
||||
const firstByInput = new Map<string, A>();
|
||||
for (const entry of compiled) {
|
||||
if (entry.holdKey === entry.pressKey) {
|
||||
issues[entry.action] = 'Hold and press are the same button';
|
||||
continue;
|
||||
}
|
||||
const inputKey = `${entry.holdKey ?? ''}|${entry.pressKey}`;
|
||||
const first = firstByInput.get(inputKey);
|
||||
if (first) {
|
||||
issues[entry.action] = `Same as ${label(first)}`;
|
||||
issues[first] ??= `Same as ${label(entry.action)}`;
|
||||
continue;
|
||||
}
|
||||
firstByInput.set(inputKey, entry.action);
|
||||
const holdSource = entry.holdKey === null ? holdKeys.get(entry.pressKey) : undefined;
|
||||
if (holdSource) {
|
||||
issues[entry.action] =
|
||||
`${formatSource(holdSource, context, true)} is also a hold button on this step`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function findControllerReviewBindingIssues(
|
||||
bindings: ResolvedControllerReviewBindings,
|
||||
context: ControllerReviewInputContext,
|
||||
): Partial<Record<ControllerReviewAction, string>> {
|
||||
return findControllerScreenBindingIssues(CONTROLLER_REVIEW_SCREEN, bindings, context);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { CONTROLLER_FIELD_GROUPING_SCREEN } from './controller-field-grouping';
|
||||
import {
|
||||
CONTROLLER_REVIEW_SCREEN,
|
||||
isControllerScreenAction,
|
||||
parseControllerReviewBinding,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
} from './controller-review';
|
||||
|
||||
/** Every dialog with its own remappable controller layout, in Alt+C order. */
|
||||
export const CONTROLLER_SCREENS: readonly ControllerScreenDefinition[] = [
|
||||
CONTROLLER_REVIEW_SCREEN as unknown as ControllerScreenDefinition,
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN as unknown as ControllerScreenDefinition,
|
||||
];
|
||||
|
||||
export type ControllerScreenId = ControllerScreenDefinition['id'];
|
||||
export type ControllerScreenBindingsKey = ControllerScreenDefinition['configKey'];
|
||||
|
||||
export function getControllerScreen(id: ControllerScreenId): ControllerScreenDefinition {
|
||||
return CONTROLLER_SCREENS.find((screen) => screen.id === id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a per-action patch for one screen (`{ "continue": { … } }`). Returns null when any
|
||||
* key is not an action of that screen or any binding is malformed.
|
||||
*/
|
||||
export function parseControllerScreenBindingsPatch(
|
||||
screen: ControllerScreenDefinition,
|
||||
value: unknown,
|
||||
): Partial<ControllerScreenBindings> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
const bindings: Partial<ControllerScreenBindings> = {};
|
||||
for (const [action, rawBinding] of Object.entries(value)) {
|
||||
if (!isControllerScreenAction(screen, action)) return null;
|
||||
const parsed = parseControllerReviewBinding(rawBinding);
|
||||
if (!parsed) return null;
|
||||
bindings[action] = parsed;
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
@@ -158,6 +158,7 @@ export const IPC_CHANNELS = {
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
controllerSuspendedChanged: 'controller:suspended-changed',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
changelogOpen: 'changelog:open',
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings';
|
||||
import type { SubtitlePosition } from '../../types/subtitle';
|
||||
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
|
||||
import { CONTROLLER_SCREENS, parseControllerScreenBindingsPatch } from '../controller-screens';
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
@@ -242,6 +243,20 @@ function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindin
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/** Copies each screen's per-action patch (`reviewBindings`, `fieldGroupingBindings`). */
|
||||
function parseControllerScreenBindingPatches(
|
||||
source: Record<string, unknown>,
|
||||
target: Pick<ControllerConfigUpdate, 'reviewBindings' | 'fieldGroupingBindings'>,
|
||||
): boolean {
|
||||
for (const screen of CONTROLLER_SCREENS) {
|
||||
if (source[screen.configKey] === undefined) continue;
|
||||
const parsed = parseControllerScreenBindingsPatch(screen, source[screen.configKey]);
|
||||
if (!parsed) return false;
|
||||
(target as Record<string, unknown>)[screen.configKey] = parsed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
||||
if (!isObject(value)) return null;
|
||||
const update: ControllerConfigUpdate = {};
|
||||
@@ -269,6 +284,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
||||
if (!parsed) return null;
|
||||
update.bindings = parsed;
|
||||
}
|
||||
if (!parseControllerScreenBindingPatches(value, update)) return null;
|
||||
|
||||
if (value.profiles !== undefined) {
|
||||
if (!isObject(value.profiles)) return null;
|
||||
@@ -291,6 +307,7 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
||||
if (!parsed) return null;
|
||||
profile.bindings = parsed;
|
||||
}
|
||||
if (!parseControllerScreenBindingPatches(rawProfile, profile)) return null;
|
||||
profiles[profileId] = profile;
|
||||
}
|
||||
update.profiles = profiles;
|
||||
|
||||
@@ -26,8 +26,11 @@ import type {
|
||||
ControllerConfig,
|
||||
ResolvedControllerProfileConfig,
|
||||
ControllerTriggerInputMode,
|
||||
ControllerFaceButtonLabels,
|
||||
Keybinding,
|
||||
ResolvedControllerBindingsConfig,
|
||||
ResolvedControllerFieldGroupingBindings,
|
||||
ResolvedControllerReviewBindings,
|
||||
} from './runtime';
|
||||
import type {
|
||||
FrequencyDictionaryMatchMode,
|
||||
@@ -221,11 +224,14 @@ export interface ResolvedConfig {
|
||||
horizontalJumpPixels: number;
|
||||
stickDeadzone: number;
|
||||
triggerInputMode: ControllerTriggerInputMode;
|
||||
faceButtonLabels: ControllerFaceButtonLabels;
|
||||
triggerDeadzone: number;
|
||||
repeatDelayMs: number;
|
||||
repeatIntervalMs: number;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
reviewBindings: ResolvedControllerReviewBindings;
|
||||
fieldGroupingBindings: ResolvedControllerFieldGroupingBindings;
|
||||
profiles: Record<string, ResolvedControllerProfileConfig>;
|
||||
};
|
||||
ankiConnect: AnkiConnectConfig & {
|
||||
|
||||
@@ -176,6 +176,8 @@ export type ControllerButtonBinding =
|
||||
|
||||
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
||||
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
||||
/** Letters printed on the face buttons: Xbox-style (A at the bottom) or Nintendo-style (A on the right). */
|
||||
export type ControllerFaceButtonLabels = 'xbox' | 'nintendo';
|
||||
export type ControllerAxisDirection = 'negative' | 'positive';
|
||||
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
|
||||
|
||||
@@ -276,16 +278,97 @@ export interface ControllerButtonIndicesConfig {
|
||||
rightTrigger?: number;
|
||||
}
|
||||
|
||||
/** Every button on the media timing review dialog, named by what it does. */
|
||||
export type ControllerReviewAction =
|
||||
| 'confirm'
|
||||
| 'cancel'
|
||||
| 'play'
|
||||
| 'resetTiming'
|
||||
| 'showEarlier'
|
||||
| 'showLater'
|
||||
| 'framePrevious'
|
||||
| 'frameNext'
|
||||
| 'frameReset'
|
||||
| 'startBack'
|
||||
| 'startForward'
|
||||
| 'previousLineAdd'
|
||||
| 'previousLineRemove'
|
||||
| 'endBack'
|
||||
| 'endForward'
|
||||
| 'nextLineAdd'
|
||||
| 'nextLineRemove'
|
||||
| 'useOriginal'
|
||||
| 'keepEditing'
|
||||
| 'skipMedia'
|
||||
| 'discard';
|
||||
|
||||
/** Every button on the duplicate-card field grouping dialog, named by what it does. */
|
||||
export type ControllerFieldGroupingAction =
|
||||
| 'selectOriginal'
|
||||
| 'selectNew'
|
||||
| 'toggleDeleteDuplicate'
|
||||
| 'continue'
|
||||
| 'cancel'
|
||||
| 'confirmMerge'
|
||||
| 'back'
|
||||
| 'cancelMerge'
|
||||
| 'toggleView'
|
||||
| 'scrollUp'
|
||||
| 'scrollDown';
|
||||
|
||||
export type ControllerFaceLetter = 'a' | 'b' | 'x' | 'y';
|
||||
export type ControllerDpadDirection = 'left' | 'right' | 'up' | 'down';
|
||||
|
||||
/**
|
||||
* One input in a review binding. `button` and `axis` are raw indices recorded by Learn;
|
||||
* `face` (printed letter, via `faceButtonLabels`) and `dpad` (any D-pad report) keep the
|
||||
* defaults working on every controller layout.
|
||||
*/
|
||||
export type ControllerReviewInput =
|
||||
| { kind: 'button'; buttonIndex: number }
|
||||
| { kind: 'axis'; axisIndex: number; direction: ControllerAxisDirection }
|
||||
| { kind: 'face'; letter: ControllerFaceLetter }
|
||||
| { kind: 'dpad'; direction: ControllerDpadDirection };
|
||||
|
||||
/** Press `press`, optionally while holding `hold`. */
|
||||
export type ControllerReviewBindingConfig =
|
||||
| { kind: 'none' }
|
||||
| { kind?: 'combo'; press: ControllerReviewInput; hold?: ControllerReviewInput | null };
|
||||
|
||||
export type ResolvedControllerReviewBinding =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'combo'; press: ControllerReviewInput; hold: ControllerReviewInput | null };
|
||||
|
||||
export type ControllerReviewBindingsConfig = Partial<
|
||||
Record<ControllerReviewAction, ControllerReviewBindingConfig>
|
||||
>;
|
||||
export type ResolvedControllerReviewBindings = Record<
|
||||
ControllerReviewAction,
|
||||
ResolvedControllerReviewBinding
|
||||
>;
|
||||
|
||||
export type ControllerFieldGroupingBindingsConfig = Partial<
|
||||
Record<ControllerFieldGroupingAction, ControllerReviewBindingConfig>
|
||||
>;
|
||||
export type ResolvedControllerFieldGroupingBindings = Record<
|
||||
ControllerFieldGroupingAction,
|
||||
ResolvedControllerReviewBinding
|
||||
>;
|
||||
|
||||
export interface ControllerProfileConfig {
|
||||
label?: string;
|
||||
buttonIndices?: ControllerButtonIndicesConfig;
|
||||
bindings?: ControllerBindingsConfig;
|
||||
reviewBindings?: ControllerReviewBindingsConfig;
|
||||
fieldGroupingBindings?: ControllerFieldGroupingBindingsConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedControllerProfileConfig {
|
||||
label: string;
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
reviewBindings: ResolvedControllerReviewBindings;
|
||||
fieldGroupingBindings: ResolvedControllerFieldGroupingBindings;
|
||||
}
|
||||
|
||||
export interface ControllerConfig {
|
||||
@@ -297,11 +380,14 @@ export interface ControllerConfig {
|
||||
horizontalJumpPixels?: number;
|
||||
stickDeadzone?: number;
|
||||
triggerInputMode?: ControllerTriggerInputMode;
|
||||
faceButtonLabels?: ControllerFaceButtonLabels;
|
||||
triggerDeadzone?: number;
|
||||
repeatDelayMs?: number;
|
||||
repeatIntervalMs?: number;
|
||||
buttonIndices?: ControllerButtonIndicesConfig;
|
||||
bindings?: ControllerBindingsConfig;
|
||||
reviewBindings?: ControllerReviewBindingsConfig;
|
||||
fieldGroupingBindings?: ControllerFieldGroupingBindingsConfig;
|
||||
profiles?: Record<string, ControllerProfileConfig>;
|
||||
}
|
||||
|
||||
@@ -586,6 +672,7 @@ export interface ElectronAPI {
|
||||
callback: (payload: SessionNumericSelectionStartPayload) => void,
|
||||
) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onControllerSuspendedChanged: (callback: (suspended: boolean) => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
|
||||
|
||||
Reference in new issue
Block a user