fix(controller): save remaps per profile, gate modals on enabled (#69)

This commit is contained in:
2026-05-16 20:43:27 -07:00
committed by GitHub
parent 49f89e6452
commit 5250ca8214
31 changed files with 1639 additions and 463 deletions
+98
View File
@@ -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(
+1
View File
@@ -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',
+7
View File
@@ -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}`,
+423
View File
@@ -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;
}
}
}
+2 -341
View File
@@ -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(
+53
View File
@@ -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[] = [];
+64
View File
@@ -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');
});
+87 -15
View File
@@ -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;
+22
View File
@@ -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,
};
}
+1 -1
View File
@@ -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[] = [];
+49 -34
View File
@@ -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,
);
}
+3 -3
View File
@@ -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();
}
-2
View File
@@ -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');
}
}
});
+56 -2
View File
@@ -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 });
}
});
+12 -2
View File
@@ -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();
+75 -10
View File
@@ -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();
}
});
}
+22 -7
View File
@@ -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
View File
@@ -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);
}
+96 -31
View File
@@ -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;
+2
View File
@@ -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;
+13
View File
@@ -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 {