mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(controller): save remaps per profile, gate modals on enabled (#69)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
Exclude<ControllerButtonBinding, 'none'>,
|
||||
keyof Required<ControllerButtonIndicesConfig>
|
||||
> = {
|
||||
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<string, ControllerDpadFallback>;
|
||||
|
||||
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<ResolvedControllerBindingsConfig>;
|
||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
|
||||
function resolveLegacyDiscreteBinding(
|
||||
value: ControllerButtonBinding,
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
Exclude<ControllerButtonBinding, 'none'>,
|
||||
keyof Required<ControllerButtonIndicesConfig>
|
||||
> = {
|
||||
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<string, ControllerDpadFallback>;
|
||||
|
||||
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<ControllerButtonIndicesConfig>,
|
||||
): 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(
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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<typeof applyControllerConfigUpdate>[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');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
||||
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
|
||||
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
|
||||
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
|
||||
|
||||
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<string, unknown>)[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<string, unknown>)[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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -67,5 +67,5 @@ export function createControllerStatusIndicator(
|
||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
||||
}
|
||||
|
||||
return { update };
|
||||
return { show, update };
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
let toastTimer: ReturnType<typeof setTimeout> | 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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerKey: string | null = null;
|
||||
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | 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<typeof window.electronAPI.saveControllerConfig>[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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+15
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ControllerConfigUpdate['buttonIndices']> = {};
|
||||
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<ControllerConfigUpdate['bindings']> = {};
|
||||
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<ControllerConfigUpdate['bindings']>[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<ControllerConfigUpdate['bindings']>[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<ControllerConfigUpdate['bindings']> = {};
|
||||
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<ControllerConfigUpdate['bindings']>[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<ControllerConfigUpdate['profiles']> = 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<ControllerConfigUpdate['profiles']>[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<ControllerConfigUpdate['bindings']>[typeof key];
|
||||
}
|
||||
update.bindings = bindings;
|
||||
update.profiles = profiles;
|
||||
}
|
||||
|
||||
return update;
|
||||
|
||||
@@ -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<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
profiles: Record<string, ResolvedControllerProfileConfig>;
|
||||
};
|
||||
ankiConnect: AnkiConnectConfig & {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -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<ControllerButtonIndicesConfig>;
|
||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
||||
}
|
||||
|
||||
export interface ControllerConfig {
|
||||
enabled?: boolean;
|
||||
preferredGamepadId?: string;
|
||||
@@ -241,6 +253,7 @@ export interface ControllerConfig {
|
||||
repeatIntervalMs?: number;
|
||||
buttonIndices?: ControllerButtonIndicesConfig;
|
||||
bindings?: ControllerBindingsConfig;
|
||||
profiles?: Record<string, ControllerProfileConfig>;
|
||||
}
|
||||
|
||||
export interface ControllerPreferenceUpdate {
|
||||
|
||||
Reference in New Issue
Block a user