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
+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);
}