diff --git a/changes/controller-config-enables-input.md b/changes/controller-config-enables-input.md new file mode 100644 index 00000000..fc72de07 --- /dev/null +++ b/changes/controller-config-enables-input.md @@ -0,0 +1,6 @@ +type: fixed +area: overlay + +- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually. +- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap. +- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults. diff --git a/config.example.jsonc b/config.example.jsonc index 64a5a162..9d2d3c68 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -138,7 +138,8 @@ "axisIndex": 4, // Raw axis index captured for this analog controller action. "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. - } // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index d4726d01..5fbcb9c7 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -608,8 +608,12 @@ Important behavior: - Controller input is only active while keyboard-only mode is enabled. - Keyboard-only mode continues to work normally without a controller. - By default SubMiner uses the first connected controller. +- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`. - `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`. -- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action. +- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled. +- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action. +- Click the reset button beside the edit pencil to restore one binding to the built-in default. +- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile. - `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`. - The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block. - `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. @@ -658,6 +662,15 @@ Important behavior: "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" }, "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }, }, + "profiles": { + "Xbox Wireless Controller": { + "label": "Xbox Wireless Controller", + "bindings": { + "toggleLookup": { "kind": "button", "buttonIndex": 0 }, + "mineCard": { "kind": "button", "buttonIndex": 2 }, + }, + }, + }, }, } ``` @@ -678,7 +691,7 @@ Default logical mapping: - `L3`: toggle mpv pause - `L2` / `R2`: unbound by default -Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings. +Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles[""]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings. If you bind a discrete action to an axis manually, include `direction`: @@ -692,15 +705,15 @@ If you bind a discrete action to an axis manually, include `direction`: } ``` -Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct. +Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct. If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default. -If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal. +If one controller reports non-standard raw button numbers, override `controller.profiles[""].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile. If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging. -Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field. +Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field. ### Manual Card Update Shortcuts diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 406dc15d..503b1f15 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo 6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation. 7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time. -The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller. +After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller. See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 64a5a162..9d2d3c68 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -138,7 +138,8 @@ "axisIndex": 4, // Raw axis index captured for this analog controller action. "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. - } // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. + "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== diff --git a/docs-site/usage.md b/docs-site/usage.md index 8f42c1b0..646b7f16 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -283,13 +283,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro ### Getting Started 1. Connect a controller before or after launching SubMiner. -2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. +2. Set `controller.enabled` to `true` in your config. 3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline. -4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. -5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. -6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. +4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. +5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. +6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. +7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. -By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`. +By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`. ### Default Button Mapping @@ -316,7 +317,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input. -All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options. +All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options. ## Keybindings diff --git a/src/config/config.test.ts b/src/config/config.test.ts index a52c7a71..d1d29bb1 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => { }); }); +test('parses controller profiles as per-gamepad binding overrides', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "buttonIndices": { + "buttonSouth": 0, + "leftTrigger": 6 + }, + "bindings": { + "toggleLookup": { "kind": "button", "buttonIndex": 0 }, + "quitMpv": "leftTrigger" + }, + "profiles": { + "8BitDo SN30": { + "label": "8BitDo SN30", + "bindings": { + "toggleLookup": { "kind": "button", "buttonIndex": 11 }, + "leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" } + } + }, + "Xbox Wireless Controller": { + "buttonIndices": { + "leftTrigger": 8 + }, + "bindings": { + "quitMpv": "leftTrigger" + } + } + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, { + kind: 'button', + buttonIndex: 11, + }); + assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, { + kind: 'button', + buttonIndex: 1, + }); + assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, { + kind: 'axis', + axisIndex: 7, + dpadFallback: 'none', + }); + assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, { + kind: 'button', + buttonIndex: 8, + }); + assert.equal( + config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger, + 8, + ); + assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 }); +}); + +test('rejects reserved controller profile ids from config', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "controller": { + "profiles": { + "__proto__": { "label": "polluted" }, + "constructor": { "label": "ctor" }, + "prototype": { "label": "proto" }, + "pad-1": { "label": "Pad 1" } + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false); + assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false); + assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false); + assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1'); + assert.equal( + warnings.some((warning) => warning.path === 'controller.profiles.constructor'), + true, + ); + assert.equal( + warnings.some((warning) => warning.path === 'controller.profiles.prototype'), + true, + ); +}); + test('controller descriptor config rejects malformed binding objects', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 991dacb4..a8fba1e8 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick< rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, + profiles: {}, }, shortcuts: { toggleVisibleOverlayGlobal: 'Alt+Shift+O', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index fb35d68f..53d6a4c8 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry( description: 'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.', }, + { + path: 'controller.profiles', + kind: 'object', + defaultValue: defaultConfig.controller.profiles, + description: + 'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.', + }, ...discreteBindings.flatMap((binding) => [ { path: `controller.bindings.${binding.id}`, diff --git a/src/config/resolve/controller.ts b/src/config/resolve/controller.ts new file mode 100644 index 00000000..95f01a0e --- /dev/null +++ b/src/config/resolve/controller.ts @@ -0,0 +1,423 @@ +import type { + ControllerAxisBinding, + ControllerAxisDirection, + ControllerButtonBinding, + ControllerButtonIndicesConfig, + ControllerDpadFallback, + ResolvedControllerAxisBinding, + ResolvedControllerBindingsConfig, + ResolvedControllerDiscreteBinding, +} from '../../types/runtime'; +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +const CONTROLLER_BUTTON_BINDINGS = [ + 'none', + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', +] as const; + +const CONTROLLER_AXIS_BINDINGS = [ + 'leftStickX', + 'leftStickY', + 'rightStickX', + 'rightStickY', +] as const; + +const CONTROLLER_AXIS_INDEX_BY_BINDING: Record = { + leftStickX: 0, + leftStickY: 1, + rightStickX: 3, + rightStickY: 4, +}; + +const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record< + Exclude, + keyof Required +> = { + select: 'select', + buttonSouth: 'buttonSouth', + buttonEast: 'buttonEast', + buttonNorth: 'buttonNorth', + buttonWest: 'buttonWest', + leftShoulder: 'leftShoulder', + rightShoulder: 'rightShoulder', + leftStickPress: 'leftStickPress', + rightStickPress: 'rightStickPress', + leftTrigger: 'leftTrigger', + rightTrigger: 'rightTrigger', +}; + +const CONTROLLER_AXIS_FALLBACK_BY_SLOT = { + leftStickHorizontal: 'horizontal', + leftStickVertical: 'vertical', + rightStickHorizontal: 'none', + rightStickVertical: 'none', +} as const satisfies Record; + +const CONTROLLER_BUTTON_INDEX_KEYS = [ + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', +] as const; + +const CONTROLLER_DISCRETE_BINDING_KEYS = [ + 'toggleLookup', + 'closeLookup', + 'toggleKeyboardOnlyMode', + 'mineCard', + 'quitMpv', + 'previousAudio', + 'nextAudio', + 'playCurrentAudio', + 'toggleMpvPause', +] as const; + +const CONTROLLER_AXIS_BINDING_KEYS = [ + 'leftStickHorizontal', + 'leftStickVertical', + 'rightStickHorizontal', + 'rightStickVertical', +] as const; + +const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']); + +type ControllerBindingsTarget = Required; +type ControllerButtonIndicesTarget = Required; + +function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection { + return value === 'negative' || value === 'positive'; +} + +function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback { + return value === 'none' || value === 'horizontal' || value === 'vertical'; +} + +function resolveLegacyDiscreteBinding( + value: ControllerButtonBinding, + buttonIndices: Required, +): ResolvedControllerDiscreteBinding { + if (value === 'none') { + return { kind: 'none' }; + } + return { + kind: 'button', + buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]], + }; +} + +function resolveLegacyAxisBinding( + value: ControllerAxisBinding, + slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, +): ResolvedControllerAxisBinding { + return { + kind: 'axis', + axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value], + dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], + }; +} + +function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null { + if (!isObject(value) || typeof value.kind !== 'string') return null; + if (value.kind === 'none') { + return { kind: 'none' }; + } + if (value.kind === 'button') { + return typeof value.buttonIndex === 'number' && + Number.isInteger(value.buttonIndex) && + value.buttonIndex >= 0 + ? { kind: 'button', buttonIndex: value.buttonIndex } + : null; + } + if (value.kind === 'axis') { + return typeof value.axisIndex === 'number' && + Number.isInteger(value.axisIndex) && + value.axisIndex >= 0 && + isControllerAxisDirection(value.direction) + ? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction } + : null; + } + return null; +} + +function parseAxisBindingObject( + value: unknown, + slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, +): ResolvedControllerAxisBinding | null { + if (isObject(value) && value.kind === 'none') { + return { kind: 'none' }; + } + if (!isObject(value) || value.kind !== 'axis') return null; + if ( + typeof value.axisIndex !== 'number' || + !Number.isInteger(value.axisIndex) || + value.axisIndex < 0 + ) { + return null; + } + if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) { + return null; + } + return { + kind: 'axis', + axisIndex: value.axisIndex, + dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], + }; +} + +function applyControllerButtonIndices( + source: unknown, + target: ControllerButtonIndicesTarget, + pathPrefix: string, + warn: ResolveContext['warn'], +): void { + if (!isObject(source)) return; + + for (const key of CONTROLLER_BUTTON_INDEX_KEYS) { + const value = asNumber(source[key]); + if (value !== undefined && value >= 0 && Number.isInteger(value)) { + target[key] = value; + } else if (source[key] !== undefined) { + warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.'); + } + } +} + +function applyControllerBindings( + source: unknown, + target: ControllerBindingsTarget, + buttonIndices: ControllerButtonIndicesTarget, + pathPrefix: string, + warn: ResolveContext['warn'], +): void { + if (!isObject(source)) return; + + for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) { + const bindingValue = source[key]; + const legacyValue = asString(bindingValue); + if ( + legacyValue !== undefined && + CONTROLLER_BUTTON_BINDINGS.includes( + legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number], + ) + ) { + target[key] = resolveLegacyDiscreteBinding( + legacyValue as ControllerButtonBinding, + buttonIndices, + ); + continue; + } + const parsedObject = parseDiscreteBindingObject(bindingValue); + if (parsedObject) { + target[key] = parsedObject; + } else if (bindingValue !== undefined) { + warn( + `${pathPrefix}.${key}`, + bindingValue, + target[key], + "Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.", + ); + } + } + + for (const key of CONTROLLER_AXIS_BINDING_KEYS) { + const bindingValue = source[key]; + const legacyValue = asString(bindingValue); + if ( + legacyValue !== undefined && + CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number]) + ) { + target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key); + continue; + } + if (legacyValue === 'none') { + target[key] = { kind: 'none' }; + continue; + } + const parsedObject = parseAxisBindingObject(bindingValue, key); + if (parsedObject) { + target[key] = parsedObject; + } else if (bindingValue !== undefined) { + warn( + `${pathPrefix}.${key}`, + bindingValue, + target[key], + "Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.", + ); + } + } +} + +export function applyControllerConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + if (!isObject(src.controller)) return; + + const enabled = asBoolean(src.controller.enabled); + if (enabled !== undefined) { + resolved.controller.enabled = enabled; + } else if (src.controller.enabled !== undefined) { + warn( + 'controller.enabled', + src.controller.enabled, + resolved.controller.enabled, + 'Expected boolean.', + ); + } + + const preferredGamepadId = asString(src.controller.preferredGamepadId); + if (preferredGamepadId !== undefined) { + resolved.controller.preferredGamepadId = preferredGamepadId; + } + + const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel); + if (preferredGamepadLabel !== undefined) { + resolved.controller.preferredGamepadLabel = preferredGamepadLabel; + } + + const smoothScroll = asBoolean(src.controller.smoothScroll); + if (smoothScroll !== undefined) { + resolved.controller.smoothScroll = smoothScroll; + } else if (src.controller.smoothScroll !== undefined) { + warn( + 'controller.smoothScroll', + src.controller.smoothScroll, + resolved.controller.smoothScroll, + 'Expected boolean.', + ); + } + + const triggerInputMode = asString(src.controller.triggerInputMode); + if ( + triggerInputMode === 'auto' || + triggerInputMode === 'digital' || + triggerInputMode === 'analog' + ) { + resolved.controller.triggerInputMode = triggerInputMode; + } else if (src.controller.triggerInputMode !== undefined) { + warn( + 'controller.triggerInputMode', + src.controller.triggerInputMode, + resolved.controller.triggerInputMode, + "Expected 'auto', 'digital', or 'analog'.", + ); + } + + const boundedNumberKeys = [ + 'scrollPixelsPerSecond', + 'horizontalJumpPixels', + 'repeatDelayMs', + 'repeatIntervalMs', + ] as const; + for (const key of boundedNumberKeys) { + const value = asNumber(src.controller[key]); + if (value !== undefined && Math.floor(value) > 0) { + resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key]; + } else if (src.controller[key] !== undefined) { + warn( + `controller.${key}`, + src.controller[key], + resolved.controller[key], + 'Expected positive number.', + ); + } + } + + const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const; + for (const key of deadzoneKeys) { + const value = asNumber(src.controller[key]); + if (value !== undefined && value >= 0 && value <= 1) { + resolved.controller[key] = value as (typeof resolved.controller)[typeof key]; + } else if (src.controller[key] !== undefined) { + warn( + `controller.${key}`, + src.controller[key], + resolved.controller[key], + 'Expected number between 0 and 1.', + ); + } + } + + applyControllerButtonIndices( + src.controller.buttonIndices, + resolved.controller.buttonIndices, + 'controller.buttonIndices', + warn, + ); + applyControllerBindings( + src.controller.bindings, + resolved.controller.bindings, + resolved.controller.buttonIndices, + 'controller.bindings', + warn, + ); + + if (isObject(src.controller.profiles)) { + for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) { + if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) { + warn( + `controller.profiles.${profileId}`, + rawProfile, + undefined, + 'Reserved profile id is not allowed.', + ); + continue; + } + if (!isObject(rawProfile)) { + warn( + `controller.profiles.${profileId}`, + rawProfile, + undefined, + 'Expected controller profile object.', + ); + continue; + } + + const label = asString(rawProfile.label); + if (rawProfile.label !== undefined && label === undefined) { + warn( + `controller.profiles.${profileId}.label`, + rawProfile.label, + profileId, + 'Expected string.', + ); + } + + const profile = { + label: label ?? profileId, + buttonIndices: structuredClone(resolved.controller.buttonIndices), + bindings: structuredClone(resolved.controller.bindings), + }; + applyControllerButtonIndices( + rawProfile.buttonIndices, + profile.buttonIndices, + `controller.profiles.${profileId}.buttonIndices`, + warn, + ); + applyControllerBindings( + rawProfile.bindings, + profile.bindings, + profile.buttonIndices, + `controller.profiles.${profileId}.bindings`, + warn, + ); + resolved.controller.profiles[profileId] = profile; + } + } +} diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 6a56c843..838f954d 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -1,150 +1,7 @@ -import type { - ControllerAxisBinding, - ControllerAxisBindingConfig, - ControllerAxisDirection, - ControllerButtonBinding, - ControllerButtonIndicesConfig, - ControllerDpadFallback, - ControllerDiscreteBindingConfig, - ResolvedControllerAxisBinding, - ResolvedControllerDiscreteBinding, -} from '../../types/runtime'; import { ResolveContext } from './context'; +import { applyControllerConfig } from './controller'; import { asBoolean, asNumber, asString, isObject } from './shared'; -const CONTROLLER_BUTTON_BINDINGS = [ - 'none', - 'select', - 'buttonSouth', - 'buttonEast', - 'buttonNorth', - 'buttonWest', - 'leftShoulder', - 'rightShoulder', - 'leftStickPress', - 'rightStickPress', - 'leftTrigger', - 'rightTrigger', -] as const; - -const CONTROLLER_AXIS_BINDINGS = [ - 'leftStickX', - 'leftStickY', - 'rightStickX', - 'rightStickY', -] as const; - -const CONTROLLER_AXIS_INDEX_BY_BINDING: Record = { - leftStickX: 0, - leftStickY: 1, - rightStickX: 3, - rightStickY: 4, -}; - -const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record< - Exclude, - keyof Required -> = { - select: 'select', - buttonSouth: 'buttonSouth', - buttonEast: 'buttonEast', - buttonNorth: 'buttonNorth', - buttonWest: 'buttonWest', - leftShoulder: 'leftShoulder', - rightShoulder: 'rightShoulder', - leftStickPress: 'leftStickPress', - rightStickPress: 'rightStickPress', - leftTrigger: 'leftTrigger', - rightTrigger: 'rightTrigger', -}; - -const CONTROLLER_AXIS_FALLBACK_BY_SLOT = { - leftStickHorizontal: 'horizontal', - leftStickVertical: 'vertical', - rightStickHorizontal: 'none', - rightStickVertical: 'none', -} as const satisfies Record; - -function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection { - return value === 'negative' || value === 'positive'; -} - -function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback { - return value === 'none' || value === 'horizontal' || value === 'vertical'; -} - -function resolveLegacyDiscreteBinding( - value: ControllerButtonBinding, - buttonIndices: Required, -): ResolvedControllerDiscreteBinding { - if (value === 'none') { - return { kind: 'none' }; - } - return { - kind: 'button', - buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]], - }; -} - -function resolveLegacyAxisBinding( - value: ControllerAxisBinding, - slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, -): ResolvedControllerAxisBinding { - return { - kind: 'axis', - axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value], - dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], - }; -} - -function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null { - if (!isObject(value) || typeof value.kind !== 'string') return null; - if (value.kind === 'none') { - return { kind: 'none' }; - } - if (value.kind === 'button') { - return typeof value.buttonIndex === 'number' && - Number.isInteger(value.buttonIndex) && - value.buttonIndex >= 0 - ? { kind: 'button', buttonIndex: value.buttonIndex } - : null; - } - if (value.kind === 'axis') { - return typeof value.axisIndex === 'number' && - Number.isInteger(value.axisIndex) && - value.axisIndex >= 0 && - isControllerAxisDirection(value.direction) - ? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction } - : null; - } - return null; -} - -function parseAxisBindingObject( - value: unknown, - slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT, -): ResolvedControllerAxisBinding | null { - if (isObject(value) && value.kind === 'none') { - return { kind: 'none' }; - } - if (!isObject(value) || value.kind !== 'axis') return null; - if ( - typeof value.axisIndex !== 'number' || - !Number.isInteger(value.axisIndex) || - value.axisIndex < 0 - ) { - return null; - } - if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) { - return null; - } - return { - kind: 'axis', - axisIndex: value.axisIndex, - dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot], - }; -} - export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; @@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { } } - if (isObject(src.controller)) { - const enabled = asBoolean(src.controller.enabled); - if (enabled !== undefined) { - resolved.controller.enabled = enabled; - } else if (src.controller.enabled !== undefined) { - warn( - 'controller.enabled', - src.controller.enabled, - resolved.controller.enabled, - 'Expected boolean.', - ); - } - - const preferredGamepadId = asString(src.controller.preferredGamepadId); - if (preferredGamepadId !== undefined) { - resolved.controller.preferredGamepadId = preferredGamepadId; - } - - const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel); - if (preferredGamepadLabel !== undefined) { - resolved.controller.preferredGamepadLabel = preferredGamepadLabel; - } - - const smoothScroll = asBoolean(src.controller.smoothScroll); - if (smoothScroll !== undefined) { - resolved.controller.smoothScroll = smoothScroll; - } else if (src.controller.smoothScroll !== undefined) { - warn( - 'controller.smoothScroll', - src.controller.smoothScroll, - resolved.controller.smoothScroll, - 'Expected boolean.', - ); - } - - const triggerInputMode = asString(src.controller.triggerInputMode); - if ( - triggerInputMode === 'auto' || - triggerInputMode === 'digital' || - triggerInputMode === 'analog' - ) { - resolved.controller.triggerInputMode = triggerInputMode; - } else if (src.controller.triggerInputMode !== undefined) { - warn( - 'controller.triggerInputMode', - src.controller.triggerInputMode, - resolved.controller.triggerInputMode, - "Expected 'auto', 'digital', or 'analog'.", - ); - } - - const boundedNumberKeys = [ - 'scrollPixelsPerSecond', - 'horizontalJumpPixels', - 'repeatDelayMs', - 'repeatIntervalMs', - ] as const; - for (const key of boundedNumberKeys) { - const value = asNumber(src.controller[key]); - if (value !== undefined && Math.floor(value) > 0) { - resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key]; - } else if (src.controller[key] !== undefined) { - warn( - `controller.${key}`, - src.controller[key], - resolved.controller[key], - 'Expected positive number.', - ); - } - } - - const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const; - for (const key of deadzoneKeys) { - const value = asNumber(src.controller[key]); - if (value !== undefined && value >= 0 && value <= 1) { - resolved.controller[key] = value as (typeof resolved.controller)[typeof key]; - } else if (src.controller[key] !== undefined) { - warn( - `controller.${key}`, - src.controller[key], - resolved.controller[key], - 'Expected number between 0 and 1.', - ); - } - } - - if (isObject(src.controller.buttonIndices)) { - const buttonIndexKeys = [ - 'select', - 'buttonSouth', - 'buttonEast', - 'buttonNorth', - 'buttonWest', - 'leftShoulder', - 'rightShoulder', - 'leftStickPress', - 'rightStickPress', - 'leftTrigger', - 'rightTrigger', - ] as const; - - for (const key of buttonIndexKeys) { - const value = asNumber(src.controller.buttonIndices[key]); - if (value !== undefined && value >= 0 && Number.isInteger(value)) { - resolved.controller.buttonIndices[key] = value; - } else if (src.controller.buttonIndices[key] !== undefined) { - warn( - `controller.buttonIndices.${key}`, - src.controller.buttonIndices[key], - resolved.controller.buttonIndices[key], - 'Expected non-negative integer.', - ); - } - } - } - - if (isObject(src.controller.bindings)) { - const buttonBindingKeys = [ - 'toggleLookup', - 'closeLookup', - 'toggleKeyboardOnlyMode', - 'mineCard', - 'quitMpv', - 'previousAudio', - 'nextAudio', - 'playCurrentAudio', - 'toggleMpvPause', - ] as const; - - for (const key of buttonBindingKeys) { - const bindingValue = src.controller.bindings[key]; - const legacyValue = asString(bindingValue); - if ( - legacyValue !== undefined && - CONTROLLER_BUTTON_BINDINGS.includes( - legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number], - ) - ) { - resolved.controller.bindings[key] = resolveLegacyDiscreteBinding( - legacyValue as ControllerButtonBinding, - resolved.controller.buttonIndices, - ); - continue; - } - const parsedObject = parseDiscreteBindingObject(bindingValue); - if (parsedObject) { - resolved.controller.bindings[key] = parsedObject; - } else if (bindingValue !== undefined) { - warn( - `controller.bindings.${key}`, - bindingValue, - resolved.controller.bindings[key], - "Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.", - ); - } - } - - const axisBindingKeys = [ - 'leftStickHorizontal', - 'leftStickVertical', - 'rightStickHorizontal', - 'rightStickVertical', - ] as const; - - for (const key of axisBindingKeys) { - const bindingValue = src.controller.bindings[key]; - const legacyValue = asString(bindingValue); - if ( - legacyValue !== undefined && - CONTROLLER_AXIS_BINDINGS.includes( - legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number], - ) - ) { - resolved.controller.bindings[key] = resolveLegacyAxisBinding( - legacyValue as ControllerAxisBinding, - key, - ); - continue; - } - if (legacyValue === 'none') { - resolved.controller.bindings[key] = { kind: 'none' }; - continue; - } - const parsedObject = parseAxisBindingObject(bindingValue, key); - if (parsedObject) { - resolved.controller.bindings[key] = parsedObject; - } else if (bindingValue !== undefined) { - warn( - `controller.bindings.${key}`, - bindingValue, - resolved.controller.bindings[key], - "Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.", - ); - } - } - } - } + applyControllerConfig(context); if (Array.isArray(src.keybindings)) { resolved.keybindings = src.keybindings.filter( diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 8b812225..70d11c07 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -83,6 +83,7 @@ function createControllerConfigFixture() { rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const }, rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const }, }, + profiles: {}, }; } @@ -975,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon ]); }); +test('registerIpcHandlers accepts per-controller profile config updates', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const controllerSaves: unknown[] = []; + registerIpcHandlers( + createRegisterIpcDeps({ + saveControllerConfig: async (update) => { + controllerSaves.push(update); + }, + }), + registrar, + ); + + const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig); + assert.ok(saveHandler); + + const update = { + profiles: { + 'pad-1': { + label: 'Pad One', + buttonIndices: { + buttonSouth: 11, + }, + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' }, + }, + }, + }, + }; + await saveHandler({}, update); + assert.deepEqual(controllerSaves, [update]); + + await assert.rejects(async () => { + await saveHandler( + {}, + { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'axis', axisIndex: 0 }, + }, + }, + }, + }, + ); + }, /Invalid controller config payload/); + + await assert.rejects(async () => { + await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}')); + }, /Invalid controller config payload/); +}); + test('registerIpcHandlers validates dispatchSessionAction payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const dispatched: SessionActionDispatchRequest[] = []; diff --git a/src/main/controller-config-update.test.ts b/src/main/controller-config-update.test.ts index a3ffeb07..0e30e401 100644 --- a/src/main/controller-config-update.test.ts +++ b/src/main/controller-config-update.test.ts @@ -75,3 +75,67 @@ test('applyControllerConfigUpdate detaches updated binding values from the patch assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 }); }); + +test('applyControllerConfigUpdate merges per-controller profile binding leaves', () => { + const next = applyControllerConfigUpdate( + { + profiles: { + 'pad-1': { + label: 'Pad 1', + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 0 }, + closeLookup: { kind: 'button', buttonIndex: 1 }, + }, + }, + }, + }, + { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + 'pad-2': { + label: 'Pad 2', + bindings: { + mineCard: { kind: 'button', buttonIndex: 8 }, + }, + }, + }, + }, + ); + + assert.deepEqual(next.profiles?.['pad-1']?.bindings?.toggleLookup, { + kind: 'button', + buttonIndex: 11, + }); + assert.deepEqual(next.profiles?.['pad-1']?.bindings?.closeLookup, { + kind: 'button', + buttonIndex: 1, + }); + assert.deepEqual(next.profiles?.['pad-2']?.bindings?.mineCard, { + kind: 'button', + buttonIndex: 8, + }); +}); + +test('applyControllerConfigUpdate ignores reserved profile ids', () => { + const reservedProfiles = Object.create(null) as NonNullable< + Parameters[1]['profiles'] + >; + reservedProfiles.__proto__ = { label: 'polluted' }; + reservedProfiles['constructor'] = { label: 'ctor' }; + reservedProfiles['prototype'] = { label: 'proto' }; + reservedProfiles['pad-1'] = { label: 'Pad 1' }; + + const next = applyControllerConfigUpdate(undefined, { + profiles: reservedProfiles, + }); + + assert.equal(Object.getPrototypeOf(next.profiles), Object.prototype); + assert.equal(Object.hasOwn(next.profiles ?? {}, '__proto__'), false); + assert.equal(Object.hasOwn(next.profiles ?? {}, 'constructor'), false); + assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false); + assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1'); +}); diff --git a/src/main/controller-config-update.ts b/src/main/controller-config-update.ts index c8540699..69ff2c7d 100644 --- a/src/main/controller-config-update.ts +++ b/src/main/controller-config-update.ts @@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types'; type RawControllerConfig = NonNullable; type RawControllerBindings = NonNullable; +type RawControllerButtonIndices = NonNullable; +type RawControllerProfiles = NonNullable; +type RawControllerProfile = NonNullable; + +const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']); + +function mergeBindingPatch( + currentBindings: RawControllerBindings | undefined, + updateBindings: RawControllerBindings | undefined, +): RawControllerBindings | undefined { + if (!currentBindings && !updateBindings) return undefined; + const nextBindings: RawControllerBindings = { + ...(currentBindings ?? {}), + }; + + for (const [key, value] of Object.entries(updateBindings ?? {}) as Array< + [keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined] + >) { + if (value === undefined) continue; + (nextBindings as Record)[key] = structuredClone(value); + } + + return nextBindings; +} + +function mergeButtonIndexPatch( + currentButtonIndices: RawControllerButtonIndices | undefined, + updateButtonIndices: RawControllerButtonIndices | undefined, +): RawControllerButtonIndices | undefined { + if (!currentButtonIndices && !updateButtonIndices) return undefined; + return { + ...(currentButtonIndices ?? {}), + ...(updateButtonIndices ?? {}), + }; +} + +function mergeControllerProfile( + currentProfile: RawControllerProfile | undefined, + updateProfile: RawControllerProfile, +): RawControllerProfile { + const nextProfile: RawControllerProfile = { + ...(currentProfile ?? {}), + ...updateProfile, + }; + + const buttonIndices = mergeButtonIndexPatch( + currentProfile?.buttonIndices, + updateProfile.buttonIndices, + ); + if (buttonIndices) { + nextProfile.buttonIndices = buttonIndices; + } + + const bindings = mergeBindingPatch(currentProfile?.bindings, updateProfile.bindings); + if (bindings) { + nextProfile.bindings = bindings; + } + + return nextProfile; +} export function applyControllerConfigUpdate( currentController: RawConfig['controller'] | undefined, @@ -12,26 +72,38 @@ export function applyControllerConfigUpdate( ...update, }; - if (currentController?.buttonIndices || update.buttonIndices) { - nextController.buttonIndices = { - ...(currentController?.buttonIndices ?? {}), - ...(update.buttonIndices ?? {}), - }; + const buttonIndices = mergeButtonIndexPatch( + currentController?.buttonIndices, + update.buttonIndices, + ); + if (buttonIndices) { + nextController.buttonIndices = buttonIndices; } - if (currentController?.bindings || update.bindings) { - const nextBindings: RawControllerBindings = { - ...(currentController?.bindings ?? {}), - }; + const bindings = mergeBindingPatch(currentController?.bindings, update.bindings); + if (bindings) { + nextController.bindings = bindings; + } - for (const [key, value] of Object.entries(update.bindings ?? {}) as Array< - [keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined] + if (currentController?.profiles || update.profiles) { + const nextProfiles: RawControllerProfiles = {}; + for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array< + [string, RawControllerProfile] >) { - if (value === undefined) continue; - (nextBindings as Record)[key] = structuredClone(value); + if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue; + nextProfiles[profileId] = profile; } - - nextController.bindings = nextBindings; + for (const [profileId, profileUpdate] of Object.entries(update.profiles ?? {}) as Array< + [string, RawControllerProfile | undefined] + >) { + if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue; + if (profileUpdate === undefined) continue; + nextProfiles[profileId] = mergeControllerProfile( + currentController?.profiles?.[profileId], + profileUpdate, + ); + } + nextController.profiles = nextProfiles; } return nextController; diff --git a/src/renderer/controller-profile-config.ts b/src/renderer/controller-profile-config.ts new file mode 100644 index 00000000..e07717cd --- /dev/null +++ b/src/renderer/controller-profile-config.ts @@ -0,0 +1,22 @@ +import type { ResolvedControllerConfig, ResolvedControllerProfileConfig } from '../types'; + +export function getControllerProfile( + config: ResolvedControllerConfig | null, + gamepadId: string | null | undefined, +): ResolvedControllerProfileConfig | null { + if (!config || !gamepadId) return null; + return config.profiles[gamepadId] ?? null; +} + +export function resolveControllerConfigForGamepad( + config: ResolvedControllerConfig, + gamepadId: string | null | undefined, +): ResolvedControllerConfig { + const profile = getControllerProfile(config, gamepadId); + if (!profile) return config; + return { + ...config, + buttonIndices: profile.buttonIndices, + bindings: profile.bindings, + }; +} diff --git a/src/renderer/controller-status-indicator.ts b/src/renderer/controller-status-indicator.ts index 6206449e..fc43fddf 100644 --- a/src/renderer/controller-status-indicator.ts +++ b/src/renderer/controller-status-indicator.ts @@ -67,5 +67,5 @@ export function createControllerStatusIndicator( previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id)); } - return { update }; + return { show, update }; } diff --git a/src/renderer/handlers/gamepad-controller.test.ts b/src/renderer/handlers/gamepad-controller.test.ts index c6f3641a..648a9692 100644 --- a/src/renderer/handlers/gamepad-controller.test.ts +++ b/src/renderer/handlers/gamepad-controller.test.ts @@ -93,6 +93,7 @@ function createControllerConfig( ...(buttonIndexOverrides ?? {}), }), }, + profiles: {}, ...restOverrides, }; } @@ -449,6 +450,60 @@ test('gamepad controller maps left stick horizontal movement to token selection assert.deepEqual(calls, [1, 1, -1]); }); +test('gamepad controller uses active controller profile bindings before global bindings', () => { + let lookupToggles = 0; + const buttons = Array.from({ length: 12 }, () => ({ + value: 0, + pressed: false, + touched: false, + })); + buttons[11] = { value: 1, pressed: true, touched: true }; + + const controller = createGamepadController({ + getGamepads: () => [createGamepad('pad-profile', { buttons })], + getConfig: () => + ({ + ...createControllerConfig({ + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 0 }, + }, + }), + profiles: { + 'pad-profile': { + label: 'Profile Pad', + buttonIndices: DEFAULT_BUTTON_INDICES, + bindings: { + ...createControllerConfig().bindings, + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + }, + }) as ResolvedControllerConfig, + getKeyboardModeEnabled: () => true, + getLookupWindowOpen: () => false, + getInteractionBlocked: () => false, + toggleKeyboardMode: () => {}, + toggleLookup: () => { + lookupToggles += 1; + }, + closeLookup: () => {}, + moveSelection: () => {}, + mineCard: () => {}, + quitMpv: () => {}, + previousAudio: () => {}, + nextAudio: () => {}, + playCurrentAudio: () => {}, + toggleMpvPause: () => {}, + scrollPopup: () => {}, + jumpPopup: () => {}, + onState: () => {}, + }); + + controller.poll(0); + + assert.equal(lookupToggles, 1); +}); + test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => { const calls: string[] = []; const scrollCalls: number[] = []; diff --git a/src/renderer/handlers/gamepad-controller.ts b/src/renderer/handlers/gamepad-controller.ts index 3af32a8e..dddb37b1 100644 --- a/src/renderer/handlers/gamepad-controller.ts +++ b/src/renderer/handlers/gamepad-controller.ts @@ -5,6 +5,7 @@ import type { ResolvedControllerConfig, ResolvedControllerDiscreteBinding, } from '../../types'; +import { resolveControllerConfigForGamepad } from '../controller-profile-config.js'; type ControllerButtonState = { value: number; @@ -410,87 +411,101 @@ export function createGamepadController(options: GamepadControllerOptions) { resetHeldAction(jumpHold); } - let interactionAllowed = - config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); - if (config.enabled) { + const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id); + + if (activeConfig.enabled) { handleActionEdge( 'toggleKeyboardOnlyMode', - config.bindings.toggleKeyboardOnlyMode, + activeConfig.bindings.toggleKeyboardOnlyMode, activeGamepad, - config, + activeConfig, options.toggleKeyboardMode, ); } - interactionAllowed = - config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); + const interactionAllowed = + activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked(); if (!interactionAllowed) { - syncBlockedInteractionState(activeGamepad, config, now); + syncBlockedInteractionState(activeGamepad, activeConfig, now); return; } handleActionEdge( 'toggleLookup', - config.bindings.toggleLookup, + activeConfig.bindings.toggleLookup, activeGamepad, - config, + activeConfig, options.toggleLookup, ); handleActionEdge( 'closeLookup', - config.bindings.closeLookup, + activeConfig.bindings.closeLookup, activeGamepad, - config, + activeConfig, options.closeLookup, ); - handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard); - handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv); + handleActionEdge( + 'mineCard', + activeConfig.bindings.mineCard, + activeGamepad, + activeConfig, + options.mineCard, + ); + handleActionEdge( + 'quitMpv', + activeConfig.bindings.quitMpv, + activeGamepad, + activeConfig, + options.quitMpv, + ); - const activationThreshold = Math.max(config.stickDeadzone, 0.55); + const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55); if (options.getLookupWindowOpen()) { handleActionEdge( 'previousAudio', - config.bindings.previousAudio, + activeConfig.bindings.previousAudio, activeGamepad, - config, + activeConfig, options.previousAudio, ); handleActionEdge( 'nextAudio', - config.bindings.nextAudio, + activeConfig.bindings.nextAudio, activeGamepad, - config, + activeConfig, options.nextAudio, ); handleActionEdge( 'playCurrentAudio', - config.bindings.playCurrentAudio, + activeConfig.bindings.playCurrentAudio, activeGamepad, - config, + activeConfig, options.playCurrentAudio, ); const primaryScroll = resolveAxisBindingValue( activeGamepad, - config.bindings.leftStickVertical, - config.triggerDeadzone, - config.stickDeadzone, + activeConfig.bindings.leftStickVertical, + activeConfig.triggerDeadzone, + activeConfig.stickDeadzone, ); - if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) { - options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000); + if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) { + options.scrollPopup( + (primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000, + ); } handleJumpAxis( resolveAxisBindingValue( activeGamepad, - config.bindings.rightStickVertical, - config.triggerDeadzone, + activeConfig.bindings.rightStickVertical, + activeConfig.triggerDeadzone, activationThreshold, ), now, - config, + activeConfig, ); } else { resetHeldAction(jumpHold); @@ -498,21 +513,21 @@ export function createGamepadController(options: GamepadControllerOptions) { handleActionEdge( 'toggleMpvPause', - config.bindings.toggleMpvPause, + activeConfig.bindings.toggleMpvPause, activeGamepad, - config, + activeConfig, options.toggleMpvPause, ); handleSelectionAxis( resolveAxisBindingValue( activeGamepad, - config.bindings.leftStickHorizontal, - config.triggerDeadzone, + activeConfig.bindings.leftStickHorizontal, + activeConfig.triggerDeadzone, activationThreshold, ), now, - config, + activeConfig, ); } diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index a0b1e1be..a25a30b9 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -987,7 +987,7 @@ test('keyboard mode: configured controller select binding opens locally without assert.equal(openControllerSelectCount(), 1); assert.deepEqual(testGlobals.sessionActions, []); - assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']); + assert.deepEqual(testGlobals.openedModalNotifications, []); } finally { testGlobals.restore(); } @@ -1017,7 +1017,7 @@ test('keyboard mode: configured controller debug binding opens locally without d assert.equal(openControllerDebugCount(), 1); assert.deepEqual(testGlobals.sessionActions, []); - assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']); + assert.deepEqual(testGlobals.openedModalNotifications, []); } finally { testGlobals.restore(); } @@ -1049,7 +1049,7 @@ test('keyboard mode: configured controller debug binding is not swallowed while assert.equal(openControllerDebugCount(), 1); assert.deepEqual(testGlobals.sessionActions, []); - assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']); + assert.deepEqual(testGlobals.openedModalNotifications, []); } finally { testGlobals.restore(); } diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 7d26b774..3f67a0f1 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -203,13 +203,11 @@ export function createKeyboardHandlers( } if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') { - window.electronAPI.notifyOverlayModalOpened('controller-select'); options.openControllerSelectModal?.(); return; } if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') { - window.electronAPI.notifyOverlayModalOpened('controller-debug'); options.openControllerDebugModal?.(); return; } diff --git a/src/renderer/modals/controller-config-form.test.ts b/src/renderer/modals/controller-config-form.test.ts index 732b9d05..d3d486c0 100644 --- a/src/renderer/modals/controller-config-form.test.ts +++ b/src/renderer/modals/controller-config-form.test.ts @@ -144,3 +144,69 @@ test('controller config form renders rows and dispatches learn clear reset callb } } }); + +test('controller config form starts learn from badge or edit and resets from row button', () => { + const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document'); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + createElement: () => createFakeElement(), + }, + }); + + try { + const calls: string[] = []; + const container = createFakeElement(); + const form = createControllerConfigForm({ + container: container as never, + getBindings: () => + ({ + toggleLookup: { kind: 'button', buttonIndex: 0 }, + closeLookup: { kind: 'button', buttonIndex: 1 }, + toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 }, + mineCard: { kind: 'button', buttonIndex: 2 }, + quitMpv: { kind: 'button', buttonIndex: 6 }, + previousAudio: { kind: 'none' }, + nextAudio: { kind: 'button', buttonIndex: 5 }, + playCurrentAudio: { kind: 'button', buttonIndex: 4 }, + toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, + leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, + rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, + rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, + }) as never, + getLearningActionId: () => null, + getDpadLearningActionId: () => null, + onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`), + onClear: (actionId) => calls.push(`clear:${actionId}`), + onReset: (actionId) => calls.push(`reset:${actionId}`), + onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`), + onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`), + onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`), + }); + + form.render(); + + const firstRow = container.children[1]; + const right = firstRow.children[1]; + const badge = right.children[0]; + const resetButton = right.children[1]; + const editButton = right.children[2]; + + badge.dispatch('click'); + resetButton.dispatch('click'); + editButton.dispatch('click'); + + assert.deepEqual(calls, [ + 'learn:toggleLookup:discrete', + 'reset:toggleLookup', + 'learn:toggleLookup:discrete', + ]); + } finally { + if (previousDocumentDescriptor) { + Object.defineProperty(globalThis, 'document', previousDocumentDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'document'); + } + } +}); diff --git a/src/renderer/modals/controller-config-form.ts b/src/renderer/modals/controller-config-form.ts index 2b9d9971..f30ef7c6 100644 --- a/src/renderer/modals/controller-config-form.ts +++ b/src/renderer/modals/controller-config-form.ts @@ -278,6 +278,17 @@ export function createControllerConfigForm(options: { formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded, + `Learn ${definition.label}`, + (e) => { + e.stopPropagation(); + expandedRowKey = rowKey; + options.onLearn(definition.id, definition.bindingType); + }, + `Reset ${definition.label}`, + (e) => { + e.stopPropagation(); + options.onReset(definition.id); + }, ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; @@ -321,6 +332,17 @@ export function createControllerConfigForm(options: { formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded, + `Learn ${definition.label} stick`, + (e) => { + e.stopPropagation(); + expandedRowKey = rowKey; + options.onLearn(definition.id, 'axis'); + }, + `Reset ${definition.label} stick`, + (e) => { + e.stopPropagation(); + options.onReset(definition.id); + }, ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; @@ -366,6 +388,17 @@ export function createControllerConfigForm(options: { badgeText, dpadFallback === 'none', isExpanded, + `Learn ${definition.label} D-pad`, + (e) => { + e.stopPropagation(); + expandedRowKey = rowKey; + options.onDpadLearn(definition.id); + }, + `Reset ${definition.label} D-pad`, + (e) => { + e.stopPropagation(); + options.onDpadReset(definition.id); + }, ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; @@ -400,6 +433,10 @@ export function createControllerConfigForm(options: { badgeText: string, isDisabled: boolean, isExpanded: boolean, + editLabel: string, + onEdit: (e: Event) => void, + resetLabel: string, + onReset: (e: Event) => void, ): HTMLDivElement { const row = document.createElement('div'); row.className = 'controller-config-row'; @@ -412,16 +449,33 @@ export function createControllerConfigForm(options: { const right = document.createElement('div'); right.className = 'controller-config-right'; - const badge = document.createElement('span'); + const badge = document.createElement('button'); + badge.type = 'button'; badge.className = 'controller-config-badge'; if (isDisabled) badge.classList.add('disabled'); + badge.setAttribute('aria-label', editLabel); + badge.title = editLabel; badge.textContent = badgeText; + badge.addEventListener('click', onEdit); - const editIcon = document.createElement('span'); + const resetIcon = document.createElement('button'); + resetIcon.type = 'button'; + resetIcon.className = 'controller-config-reset-icon'; + resetIcon.setAttribute('aria-label', resetLabel); + resetIcon.title = resetLabel; + resetIcon.textContent = '\u21ba'; + resetIcon.addEventListener('click', onReset); + + const editIcon = document.createElement('button'); + editIcon.type = 'button'; editIcon.className = 'controller-config-edit-icon'; + editIcon.setAttribute('aria-label', editLabel); + editIcon.title = editLabel; editIcon.textContent = '\u270E'; + editIcon.addEventListener('click', onEdit); right.appendChild(badge); + right.appendChild(resetIcon); right.appendChild(editIcon); row.appendChild(label); row.appendChild(right); diff --git a/src/renderer/modals/controller-debug.test.ts b/src/renderer/modals/controller-debug.test.ts index 24b78b6d..5f2f3a84 100644 --- a/src/renderer/modals/controller-debug.test.ts +++ b/src/renderer/modals/controller-debug.test.ts @@ -76,6 +76,7 @@ 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' }, }, + profiles: {}, }; const ctx = { @@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config const modal = createControllerDebugModal(ctx as never, { modalStateReader: { isAnyModalOpen: () => false }, syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, }); modal.openControllerDebugModal(); @@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async () rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, + profiles: {}, }; const ctx = { @@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async () const modal = createControllerDebugModal(ctx as never, { modalStateReader: { isAnyModalOpen: () => false }, syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, }); modal.wireDomEvents(); @@ -244,3 +248,97 @@ test('controller debug modal copies buttonIndices config to clipboard', async () }); } }); + +test('controller debug modal stays closed and notifies when controller support is disabled', () => { + const globals = globalThis as typeof globalThis & { window?: unknown }; + const previousWindow = globals.window; + let disabledNotices = 0; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + notifyOverlayModalClosed: () => {}, + }, + }, + }); + + try { + const state = createRendererState(); + state.controllerConfig = { + enabled: false, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 900, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 320, + repeatIntervalMs: 120, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 0 }, + closeLookup: { kind: 'button', buttonIndex: 1 }, + toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 }, + mineCard: { kind: 'button', buttonIndex: 2 }, + quitMpv: { kind: 'button', buttonIndex: 6 }, + previousAudio: { kind: 'none' }, + nextAudio: { kind: 'button', buttonIndex: 5 }, + playCurrentAudio: { kind: 'button', buttonIndex: 4 }, + toggleMpvPause: { kind: 'button', buttonIndex: 9 }, + leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' }, + leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' }, + rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, + rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, + }, + profiles: {}, + }; + const ctx = { + dom: { + overlay: { classList: createClassList() }, + controllerDebugModal: { + classList: createClassList(['hidden']), + setAttribute: () => {}, + }, + controllerDebugClose: { addEventListener: () => {} }, + controllerDebugCopy: { addEventListener: () => {} }, + controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) }, + controllerDebugStatus: { textContent: '', classList: createClassList() }, + controllerDebugSummary: { textContent: '' }, + controllerDebugAxes: { textContent: '' }, + controllerDebugButtons: { textContent: '' }, + controllerDebugButtonIndices: { textContent: '' }, + }, + state, + }; + const modal = createControllerDebugModal(ctx as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => { + disabledNotices += 1; + }, + }); + + assert.equal(modal.openControllerDebugModal(), false); + + assert.equal(state.controllerDebugModalOpen, false); + assert.equal(ctx.dom.controllerDebugModal.classList.contains('hidden'), true); + assert.equal(disabledNotices, 1); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + } +}); diff --git a/src/renderer/modals/controller-debug.ts b/src/renderer/modals/controller-debug.ts index b1aa8fc6..4215b0d9 100644 --- a/src/renderer/modals/controller-debug.ts +++ b/src/renderer/modals/controller-debug.ts @@ -1,4 +1,5 @@ import type { ModalStateReader, RendererContext } from '../context'; +import { resolveControllerConfigForGamepad } from '../controller-profile-config.js'; function formatAxes(values: number[]): string { if (values.length === 0) return 'No controller axes available.'; @@ -50,6 +51,7 @@ export function createControllerDebugModal( options: { modalStateReader: Pick; syncSettingsModalSubtitleSuppression: () => void; + notifyControllerDisabled: () => void; }, ) { let toastTimer: ReturnType | null = null; @@ -114,8 +116,11 @@ export function createControllerDebugModal( : 'Connect a controller and press any button to populate raw input values.'; ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes); ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons); + const activeConfig = ctx.state.controllerConfig + ? resolveControllerConfigForGamepad(ctx.state.controllerConfig, ctx.state.activeGamepadId) + : null; ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices( - ctx.state.controllerConfig?.buttonIndices ?? null, + activeConfig?.buttonIndices ?? null, ); } @@ -136,7 +141,11 @@ export function createControllerDebugModal( } } - function openControllerDebugModal(): void { + function openControllerDebugModal(): boolean { + if (ctx.state.controllerConfig?.enabled !== true) { + options.notifyControllerDisabled(); + return false; + } ctx.state.controllerDebugModalOpen = true; options.syncSettingsModalSubtitleSuppression(); ctx.dom.overlay.classList.add('interactive'); @@ -144,6 +153,7 @@ export function createControllerDebugModal( ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false'); hideToast(); render(); + return true; } function closeControllerDebugModal(): void { diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index ef64fce5..fa7b6074 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -158,6 +158,7 @@ function buildContext() { rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, + profiles: {}, }; state.connectedGamepads = [ { id: 'pad-1', index: 0, mapping: 'standard', connected: true }, @@ -201,6 +202,7 @@ test('controller select modal saves preferred controller from dropdown selection const modal = createControllerSelectModal({ state, dom } as never, { modalStateReader: { isAnyModalOpen: () => false }, syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, }); modal.wireDomEvents(); @@ -246,6 +248,7 @@ test('controller select modal learn mode captures fresh button input and persist const modal = createControllerSelectModal({ state, dom } as never, { modalStateReader: { isAnyModalOpen: () => false }, syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, }); modal.wireDomEvents(); @@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist await Promise.resolve(); + assert.deepEqual(saved.at(-1), { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + }, + }); + assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, { + kind: 'button', + buttonIndex: 11, + }); + } finally { + domHandle.restore(); + } +}); + +test('controller select modal reset control stores the default binding in the selected 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(); + if (state.controllerConfig) { + state.controllerConfig.profiles = { + 'pad-1': { + label: 'pad-1', + buttonIndices: state.controllerConfig.buttonIndices, + bindings: { + ...state.controllerConfig.bindings, + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + }; + } + const modal = createControllerSelectModal({ state, dom } as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, + }); + + modal.wireDomEvents(); + modal.openControllerSelectModal(); + + const firstRow = dom.controllerConfigList.children[1]; + const right = firstRow.children[1]; + const resetButton = right.children[1]; + resetButton.dispatch('click'); + + await Promise.resolve(); + + assert.deepEqual(saved.at(-1), { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 0 }, + }, + }, + }, + }); + assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, { + kind: 'button', + buttonIndex: 0, + }); + } finally { + domHandle.restore(); + } +}); + +test('controller select modal binding badge starts learn mode and persists binding', 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 firstRow = dom.controllerConfigList.children[1]; + const right = firstRow.children[1]; + const badge = right.children[0]; + badge.dispatch('click'); + + state.controllerRawButtons = Array.from({ length: 12 }, () => ({ + value: 0, + pressed: false, + touched: false, + })); + state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true }; + modal.updateDevices(); + + await Promise.resolve(); + + assert.deepEqual(saved.at(-1), { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + }, + }); + } finally { + domHandle.restore(); + } +}); + +test('controller select modal learn mode falls back to global bindings without a controller', 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(); + state.connectedGamepads = []; + state.activeGamepadId = null; + const modal = createControllerSelectModal({ state, dom } as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, + }); + + modal.wireDomEvents(); + modal.openControllerSelectModal(); + + const firstRow = dom.controllerConfigList.children[1]; + firstRow.dispatch('click'); + const editPanel = dom.controllerConfigList.children[2]; + const learnButton = editPanel.children[0].children[1].children[0]; + learnButton.dispatch('click'); + + state.controllerRawButtons = Array.from({ length: 12 }, () => ({ + value: 0, + pressed: false, + touched: false, + })); + state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true }; + modal.updateDevices(); + + await Promise.resolve(); + assert.deepEqual(saved.at(-1), { bindings: { toggleLookup: { kind: 'button', buttonIndex: 11 }, @@ -290,6 +479,99 @@ test('controller select modal learn mode captures fresh button input and persist } }); +test('controller select modal edit control starts learn mode and persists binding', 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 firstRow = dom.controllerConfigList.children[1]; + const right = firstRow.children[1]; + const editButton = right.children[2]; + editButton.dispatch('click'); + + state.controllerRawButtons = Array.from({ length: 12 }, () => ({ + value: 0, + pressed: false, + touched: false, + })); + state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true }; + modal.updateDevices(); + + await Promise.resolve(); + + assert.deepEqual(saved.at(-1), { + profiles: { + 'pad-1': { + bindings: { + toggleLookup: { kind: 'button', buttonIndex: 11 }, + }, + }, + }, + }); + } finally { + domHandle.restore(); + } +}); + +test('controller select modal stays closed and notifies when controller support is disabled', async () => { + const domHandle = installFakeDom(); + let disabledNotices = 0; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + focus: () => {}, + electronAPI: { + saveControllerConfig: async () => {}, + notifyOverlayModalClosed: () => {}, + }, + }, + }); + + try { + const { state, dom } = buildContext(); + if (state.controllerConfig) state.controllerConfig.enabled = false; + const modal = createControllerSelectModal({ state, dom } as never, { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => { + disabledNotices += 1; + }, + }); + + assert.equal(modal.openControllerSelectModal(), false); + + assert.equal(state.controllerSelectModalOpen, false); + assert.equal(dom.controllerSelectModal.classList.contains('hidden'), true); + assert.equal(disabledNotices, 1); + } finally { + domHandle.restore(); + } +}); + test('controller select modal uses unique picker values for duplicate controller ids', async () => { const domHandle = installFakeDom(); @@ -315,6 +597,7 @@ test('controller select modal uses unique picker values for duplicate controller const modal = createControllerSelectModal({ state, dom } as never, { modalStateReader: { isAnyModalOpen: () => false }, syncSettingsModalSubtitleSuppression: () => {}, + notifyControllerDisabled: () => {}, }); modal.wireDomEvents(); diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index a3bba8ca..1d929a6f 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -1,4 +1,5 @@ import type { ModalStateReader, RendererContext } from '../context'; +import { resolveControllerConfigForGamepad } from '../controller-profile-config.js'; import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js'; import { createControllerConfigForm, @@ -24,6 +25,7 @@ export function createControllerSelectModal( options: { modalStateReader: Pick; syncSettingsModalSubtitleSuppression: () => void; + notifyControllerDisabled: () => void; }, ) { let selectedControllerKey: string | null = null; @@ -38,10 +40,24 @@ export function createControllerSelectModal( let dpadLearningActionId: ControllerBindingKey | null = null; let bindingCapture: ReturnType | null = null; + function getSelectedController() { + return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null; + } + + function getSelectedControllerId(): string | null { + return getSelectedController()?.id ?? null; + } + + function getSelectedControllerConfig() { + const config = ctx.state.controllerConfig; + if (!config) return null; + return resolveControllerConfigForGamepad(config, getSelectedControllerId()); + } + const controllerConfigForm = createControllerConfigForm({ container: ctx.dom.controllerConfigList, getBindings: () => - ctx.state.controllerConfig?.bindings ?? { + getSelectedControllerConfig()?.bindings ?? { toggleLookup: { kind: 'button', buttonIndex: 0 }, closeLookup: { kind: 'button', buttonIndex: 1 }, toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 }, @@ -67,7 +83,7 @@ export function createControllerSelectModal( triggerDeadzone: config?.triggerDeadzone ?? 0.5, stickDeadzone: config?.stickDeadzone ?? 0.2, }); - const currentBinding = config?.bindings[actionId]; + const currentBinding = getSelectedControllerConfig()?.bindings[actionId]; const currentDpadFallback = currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding ? currentBinding.dpadFallback @@ -216,6 +232,51 @@ export function createControllerSelectModal( ...update.bindings, } as typeof ctx.state.controllerConfig.bindings; } + if (update.profiles) { + ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {}; + for (const [profileId, profileUpdate] of Object.entries(update.profiles)) { + const currentProfile = ctx.state.controllerConfig.profiles[profileId]; + const baseProfile = currentProfile ?? { + label: profileUpdate.label ?? profileId, + buttonIndices: ctx.state.controllerConfig.buttonIndices, + bindings: ctx.state.controllerConfig.bindings, + }; + ctx.state.controllerConfig.profiles[profileId] = { + label: profileUpdate.label ?? baseProfile.label, + buttonIndices: { + ...baseProfile.buttonIndices, + ...(profileUpdate.buttonIndices ?? {}), + }, + bindings: { + ...baseProfile.bindings, + ...(profileUpdate.bindings ?? {}), + }, + } as (typeof ctx.state.controllerConfig.profiles)[string]; + } + } + } + + function buildBindingConfigUpdate( + actionId: ControllerBindingKey, + binding: ControllerBindingValue, + ): Parameters[0] { + const selected = getSelectedController(); + if (!selected) { + return { + bindings: { + [actionId]: binding, + }, + }; + } + return { + profiles: { + [selected.id]: { + bindings: { + [actionId]: binding, + }, + }, + }, + }; } async function saveBinding( @@ -224,11 +285,7 @@ export function createControllerSelectModal( ): Promise { const definition = getControllerBindingDefinition(actionId); try { - await saveControllerConfig({ - bindings: { - [actionId]: binding, - }, - }); + await saveControllerConfig(buildBindingConfigUpdate(actionId, binding)); learningActionId = null; dpadLearningActionId = null; bindingCapture = null; @@ -245,11 +302,11 @@ export function createControllerSelectModal( dpadFallback: import('../../types').ControllerDpadFallback, ): Promise { const definition = getControllerBindingDefinition(actionId); - const currentBinding = ctx.state.controllerConfig?.bindings[actionId]; + const currentBinding = getSelectedControllerConfig()?.bindings[actionId]; if (!currentBinding || currentBinding.kind !== 'axis') return; const updated = { ...currentBinding, dpadFallback }; try { - await saveControllerConfig({ bindings: { [actionId]: updated } }); + await saveControllerConfig(buildBindingConfigUpdate(actionId, updated)); dpadLearningActionId = null; bindingCapture = null; controllerConfigForm.render(); @@ -330,7 +387,11 @@ export function createControllerSelectModal( } } - function openControllerSelectModal(): void { + function openControllerSelectModal(): boolean { + if (ctx.state.controllerConfig?.enabled !== true) { + options.notifyControllerDisabled(); + return false; + } ctx.state.controllerSelectModalOpen = true; syncSelectedIndexToCurrentController(); options.syncSettingsModalSubtitleSuppression(); @@ -346,6 +407,7 @@ export function createControllerSelectModal( } else { setStatus('Choose a controller or click Learn to remap an action.'); } + return true; } function closeControllerSelectModal(): void { @@ -387,6 +449,7 @@ export function createControllerSelectModal( ); syncSelectedControllerId(); renderPicker(); + controllerConfigForm.render(); } return true; } @@ -400,6 +463,7 @@ export function createControllerSelectModal( ); syncSelectedControllerId(); renderPicker(); + controllerConfigForm.render(); } return true; } @@ -429,6 +493,7 @@ export function createControllerSelectModal( ctx.state.controllerDeviceSelectedIndex = selectedIndex; syncSelectedControllerId(); renderPicker(); + controllerConfigForm.render(); } }); } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 6bd4a292..59ffb9c6 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -128,10 +128,12 @@ const subsyncModal = createSubsyncModal(ctx, { const controllerSelectModal = createControllerSelectModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, + notifyControllerDisabled: showControllerDisabledNotice, }); const controllerDebugModal = createControllerDebugModal(ctx, { modalStateReader: { isAnyModalOpen }, syncSettingsModalSubtitleSuppression, + notifyControllerDisabled: showControllerDisabledNotice, }); const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom); const sessionHelpModal = createSessionHelpModal(ctx, { @@ -183,10 +185,14 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, openSessionHelpModal: sessionHelpModal.openSessionHelpModal, openControllerSelectModal: () => { - controllerSelectModal.openControllerSelectModal(); + if (controllerSelectModal.openControllerSelectModal()) { + window.electronAPI.notifyOverlayModalOpened('controller-select'); + } }, openControllerDebugModal: () => { - controllerDebugModal.openControllerDebugModal(); + if (controllerDebugModal.openControllerDebugModal()) { + window.electronAPI.notifyOverlayModalOpened('controller-debug'); + } }, appendClipboardVideoToQueue: () => { void window.electronAPI.appendClipboardVideoToQueue(); @@ -291,6 +297,12 @@ function applyControllerSnapshot(snapshot: { controllerDebugModal.updateSnapshot(); } +function showControllerDisabledNotice(): void { + controllerStatusIndicator.show( + 'Controller support disabled. Set controller.enabled to true in config to use controller tools.', + ); +} + function emitControllerPopupScroll(deltaPixels: number): void { if (deltaPixels === 0) return; keyboardHandlers.scrollPopupByController(0, deltaPixels); @@ -311,7 +323,7 @@ function startControllerPolling(): void { getGamepads: () => Array.from(navigator.getGamepads?.() ?? []), getConfig: () => ctx.state.controllerConfig ?? { - enabled: true, + enabled: false, preferredGamepadId: '', preferredGamepadLabel: '', smoothScroll: true, @@ -350,6 +362,7 @@ function startControllerPolling(): void { rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' }, rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' }, }, + profiles: {}, }, getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled, getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document), @@ -461,14 +474,16 @@ function registerModalOpenHandlers(): void { }); window.electronAPI.onOpenControllerSelect(() => { runGuarded('controller-select:open', () => { - controllerSelectModal.openControllerSelectModal(); - window.electronAPI.notifyOverlayModalOpened('controller-select'); + if (controllerSelectModal.openControllerSelectModal()) { + window.electronAPI.notifyOverlayModalOpened('controller-select'); + } }); }); window.electronAPI.onOpenControllerDebug(() => { runGuarded('controller-debug:open', () => { - controllerDebugModal.openControllerDebugModal(); - window.electronAPI.notifyOverlayModalOpened('controller-debug'); + if (controllerDebugModal.openControllerDebugModal()) { + window.electronAPI.notifyOverlayModalOpened('controller-debug'); + } }); }); window.electronAPI.onOpenJimaku(() => { diff --git a/src/renderer/style.css b/src/renderer/style.css index 9c3e0310..bb376e56 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1694,14 +1694,17 @@ iframe[id^='yomitan-popup'], } .controller-config-badge { - display: inline-block; + display: inline-flex; + align-items: center; padding: 2px 8px; + border: 0; border-radius: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; background: rgba(138, 173, 244, 0.12); color: var(--ctp-blue); + cursor: pointer; white-space: nowrap; } @@ -1710,12 +1713,23 @@ iframe[id^='yomitan-popup'], color: var(--ctp-overlay0); } +.controller-config-reset-icon, .controller-config-edit-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; font-size: 14px; color: var(--ctp-overlay0); + cursor: pointer; transition: color 120ms ease; } +.controller-config-row:hover .controller-config-reset-icon, .controller-config-row:hover .controller-config-edit-icon { color: var(--ctp-overlay2); } diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 9c493f8f..f73473d9 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -16,6 +16,8 @@ import type { SessionActionId, SessionActionPayload } from '../../types/session- import type { SubtitlePosition } from '../../types/subtitle'; import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; +const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']); + const SESSION_ACTION_IDS: SessionActionId[] = [ 'toggleStatsOverlay', 'toggleVisibleOverlay', @@ -166,6 +168,67 @@ function parseAxisBinding(value: unknown) { }; } +function parseControllerButtonIndices( + value: unknown, +): ControllerConfigUpdate['buttonIndices'] | null { + if (!isObject(value)) return null; + const buttonIndices: NonNullable = {}; + const keys = [ + 'select', + 'buttonSouth', + 'buttonEast', + 'buttonNorth', + 'buttonWest', + 'leftShoulder', + 'rightShoulder', + 'leftStickPress', + 'rightStickPress', + 'leftTrigger', + 'rightTrigger', + ] as const; + for (const key of keys) { + if (value[key] === undefined) continue; + if (!isInteger(value[key]) || value[key] < 0) return null; + buttonIndices[key] = value[key]; + } + return buttonIndices; +} + +function parseControllerBindings(value: unknown): ControllerConfigUpdate['bindings'] | null { + if (!isObject(value)) return null; + const bindings: NonNullable = {}; + const discreteKeys = [ + 'toggleLookup', + 'closeLookup', + 'toggleKeyboardOnlyMode', + 'mineCard', + 'quitMpv', + 'previousAudio', + 'nextAudio', + 'playCurrentAudio', + 'toggleMpvPause', + ] as const; + for (const key of discreteKeys) { + if (value[key] === undefined) continue; + const parsed = parseDiscreteBinding(value[key]); + if (!parsed) return null; + bindings[key] = parsed as NonNullable[typeof key]; + } + const axisKeys = [ + 'leftStickHorizontal', + 'leftStickVertical', + 'rightStickHorizontal', + 'rightStickVertical', + ] as const; + for (const key of axisKeys) { + if (value[key] === undefined) continue; + const parsed = parseAxisBinding(value[key]); + if (!parsed) return null; + bindings[key] = parsed as NonNullable[typeof key]; + } + return bindings; +} + export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null { if (!isObject(value)) return null; const update: ControllerConfigUpdate = {}; @@ -182,40 +245,42 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd if (typeof value.preferredGamepadLabel !== 'string') return null; update.preferredGamepadLabel = value.preferredGamepadLabel; } + if (value.buttonIndices !== undefined) { + const parsed = parseControllerButtonIndices(value.buttonIndices); + if (!parsed) return null; + update.buttonIndices = parsed; + } if (value.bindings !== undefined) { - if (!isObject(value.bindings)) return null; - const bindings: NonNullable = {}; - const discreteKeys = [ - 'toggleLookup', - 'closeLookup', - 'toggleKeyboardOnlyMode', - 'mineCard', - 'quitMpv', - 'previousAudio', - 'nextAudio', - 'playCurrentAudio', - 'toggleMpvPause', - ] as const; - for (const key of discreteKeys) { - if (value.bindings[key] === undefined) continue; - const parsed = parseDiscreteBinding(value.bindings[key]); - if (!parsed) return null; - bindings[key] = parsed as NonNullable[typeof key]; + const parsed = parseControllerBindings(value.bindings); + if (!parsed) return null; + update.bindings = parsed; + } + + if (value.profiles !== undefined) { + if (!isObject(value.profiles)) return null; + const profiles: NonNullable = Object.create(null); + for (const [profileId, rawProfile] of Object.entries(value.profiles)) { + if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) return null; + if (!isObject(rawProfile)) return null; + const profile: NonNullable[string] = {}; + if (rawProfile.label !== undefined) { + if (typeof rawProfile.label !== 'string') return null; + profile.label = rawProfile.label; + } + if (rawProfile.buttonIndices !== undefined) { + const parsed = parseControllerButtonIndices(rawProfile.buttonIndices); + if (!parsed) return null; + profile.buttonIndices = parsed; + } + if (rawProfile.bindings !== undefined) { + const parsed = parseControllerBindings(rawProfile.bindings); + if (!parsed) return null; + profile.bindings = parsed; + } + profiles[profileId] = profile; } - const axisKeys = [ - 'leftStickHorizontal', - 'leftStickVertical', - 'rightStickHorizontal', - 'rightStickVertical', - ] as const; - for (const key of axisKeys) { - if (value.bindings[key] === undefined) continue; - const parsed = parseAxisBinding(value.bindings[key]); - if (!parsed) return null; - bindings[key] = parsed as NonNullable[typeof key]; - } - update.bindings = bindings; + update.profiles = profiles; } return update; diff --git a/src/types/config.ts b/src/types/config.ts index 0d670f14..cb443dc9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -21,6 +21,7 @@ import type { import type { ControllerButtonIndicesConfig, ControllerConfig, + ResolvedControllerProfileConfig, ControllerTriggerInputMode, Keybinding, ResolvedControllerBindingsConfig, @@ -164,6 +165,7 @@ export interface ResolvedConfig { repeatIntervalMs: number; buttonIndices: Required; bindings: Required; + profiles: Record; }; ankiConnect: AnkiConnectConfig & { enabled: boolean; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index c2f26c53..2f012312 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -227,6 +227,18 @@ export interface ControllerButtonIndicesConfig { rightTrigger?: number; } +export interface ControllerProfileConfig { + label?: string; + buttonIndices?: ControllerButtonIndicesConfig; + bindings?: ControllerBindingsConfig; +} + +export interface ResolvedControllerProfileConfig { + label: string; + buttonIndices: Required; + bindings: Required; +} + export interface ControllerConfig { enabled?: boolean; preferredGamepadId?: string; @@ -241,6 +253,7 @@ export interface ControllerConfig { repeatIntervalMs?: number; buttonIndices?: ControllerButtonIndicesConfig; bindings?: ControllerBindingsConfig; + profiles?: Record; } export interface ControllerPreferenceUpdate {