mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
fix(controller): save remaps per profile, gate modals on enabled (#69)
This commit is contained in:
@@ -144,3 +144,69 @@ test('controller config form renders rows and dispatches learn clear reset callb
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('controller config form starts learn from badge or edit and resets from row button', () => {
|
||||
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
createElement: () => createFakeElement(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const calls: string[] = [];
|
||||
const container = createFakeElement();
|
||||
const form = createControllerConfigForm({
|
||||
container: container as never,
|
||||
getBindings: () =>
|
||||
({
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
}) as never,
|
||||
getLearningActionId: () => null,
|
||||
getDpadLearningActionId: () => null,
|
||||
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
||||
onClear: (actionId) => calls.push(`clear:${actionId}`),
|
||||
onReset: (actionId) => calls.push(`reset:${actionId}`),
|
||||
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
|
||||
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
|
||||
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
|
||||
});
|
||||
|
||||
form.render();
|
||||
|
||||
const firstRow = container.children[1];
|
||||
const right = firstRow.children[1];
|
||||
const badge = right.children[0];
|
||||
const resetButton = right.children[1];
|
||||
const editButton = right.children[2];
|
||||
|
||||
badge.dispatch('click');
|
||||
resetButton.dispatch('click');
|
||||
editButton.dispatch('click');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'learn:toggleLookup:discrete',
|
||||
'reset:toggleLookup',
|
||||
'learn:toggleLookup:discrete',
|
||||
]);
|
||||
} finally {
|
||||
if (previousDocumentDescriptor) {
|
||||
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -278,6 +278,17 @@ export function createControllerConfigForm(options: {
|
||||
formatFriendlyBindingLabel(binding),
|
||||
binding.kind === 'none',
|
||||
isExpanded,
|
||||
`Learn ${definition.label}`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
expandedRowKey = rowKey;
|
||||
options.onLearn(definition.id, definition.bindingType);
|
||||
},
|
||||
`Reset ${definition.label}`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -321,6 +332,17 @@ export function createControllerConfigForm(options: {
|
||||
formatFriendlyStickLabel(binding),
|
||||
binding.kind === 'none',
|
||||
isExpanded,
|
||||
`Learn ${definition.label} stick`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
expandedRowKey = rowKey;
|
||||
options.onLearn(definition.id, 'axis');
|
||||
},
|
||||
`Reset ${definition.label} stick`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
options.onReset(definition.id);
|
||||
},
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -366,6 +388,17 @@ export function createControllerConfigForm(options: {
|
||||
badgeText,
|
||||
dpadFallback === 'none',
|
||||
isExpanded,
|
||||
`Learn ${definition.label} D-pad`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
expandedRowKey = rowKey;
|
||||
options.onDpadLearn(definition.id);
|
||||
},
|
||||
`Reset ${definition.label} D-pad`,
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
options.onDpadReset(definition.id);
|
||||
},
|
||||
);
|
||||
row.addEventListener('click', () => {
|
||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
||||
@@ -400,6 +433,10 @@ export function createControllerConfigForm(options: {
|
||||
badgeText: string,
|
||||
isDisabled: boolean,
|
||||
isExpanded: boolean,
|
||||
editLabel: string,
|
||||
onEdit: (e: Event) => void,
|
||||
resetLabel: string,
|
||||
onReset: (e: Event) => void,
|
||||
): HTMLDivElement {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'controller-config-row';
|
||||
@@ -412,16 +449,33 @@ export function createControllerConfigForm(options: {
|
||||
const right = document.createElement('div');
|
||||
right.className = 'controller-config-right';
|
||||
|
||||
const badge = document.createElement('span');
|
||||
const badge = document.createElement('button');
|
||||
badge.type = 'button';
|
||||
badge.className = 'controller-config-badge';
|
||||
if (isDisabled) badge.classList.add('disabled');
|
||||
badge.setAttribute('aria-label', editLabel);
|
||||
badge.title = editLabel;
|
||||
badge.textContent = badgeText;
|
||||
badge.addEventListener('click', onEdit);
|
||||
|
||||
const editIcon = document.createElement('span');
|
||||
const resetIcon = document.createElement('button');
|
||||
resetIcon.type = 'button';
|
||||
resetIcon.className = 'controller-config-reset-icon';
|
||||
resetIcon.setAttribute('aria-label', resetLabel);
|
||||
resetIcon.title = resetLabel;
|
||||
resetIcon.textContent = '\u21ba';
|
||||
resetIcon.addEventListener('click', onReset);
|
||||
|
||||
const editIcon = document.createElement('button');
|
||||
editIcon.type = 'button';
|
||||
editIcon.className = 'controller-config-edit-icon';
|
||||
editIcon.setAttribute('aria-label', editLabel);
|
||||
editIcon.title = editLabel;
|
||||
editIcon.textContent = '\u270E';
|
||||
editIcon.addEventListener('click', onEdit);
|
||||
|
||||
right.appendChild(badge);
|
||||
right.appendChild(resetIcon);
|
||||
right.appendChild(editIcon);
|
||||
row.appendChild(label);
|
||||
row.appendChild(right);
|
||||
|
||||
@@ -76,6 +76,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
@@ -99,6 +100,7 @@ test('controller debug modal renders active controller axes, buttons, and config
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.openControllerDebugModal();
|
||||
@@ -189,6 +191,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
@@ -217,6 +220,7 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
@@ -244,3 +248,97 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('controller debug modal stays closed and notifies when controller support is disabled', () => {
|
||||
const globals = globalThis as typeof globalThis & { window?: unknown };
|
||||
const previousWindow = globals.window;
|
||||
let disabledNotices = 0;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.controllerConfig = {
|
||||
enabled: false,
|
||||
preferredGamepadId: '',
|
||||
preferredGamepadLabel: '',
|
||||
smoothScroll: true,
|
||||
scrollPixelsPerSecond: 900,
|
||||
horizontalJumpPixels: 160,
|
||||
stickDeadzone: 0.2,
|
||||
triggerInputMode: 'auto',
|
||||
triggerDeadzone: 0.5,
|
||||
repeatDelayMs: 320,
|
||||
repeatIntervalMs: 120,
|
||||
buttonIndices: {
|
||||
select: 6,
|
||||
buttonSouth: 0,
|
||||
buttonEast: 1,
|
||||
buttonWest: 2,
|
||||
buttonNorth: 3,
|
||||
leftShoulder: 4,
|
||||
rightShoulder: 5,
|
||||
leftStickPress: 9,
|
||||
rightStickPress: 10,
|
||||
leftTrigger: 6,
|
||||
rightTrigger: 7,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
||||
previousAudio: { kind: 'none' },
|
||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
const ctx = {
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
controllerDebugModal: {
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
},
|
||||
controllerDebugClose: { addEventListener: () => {} },
|
||||
controllerDebugCopy: { addEventListener: () => {} },
|
||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
||||
controllerDebugSummary: { textContent: '' },
|
||||
controllerDebugAxes: { textContent: '' },
|
||||
controllerDebugButtons: { textContent: '' },
|
||||
controllerDebugButtonIndices: { textContent: '' },
|
||||
},
|
||||
state,
|
||||
};
|
||||
const modal = createControllerDebugModal(ctx as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {
|
||||
disabledNotices += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(modal.openControllerDebugModal(), false);
|
||||
|
||||
assert.equal(state.controllerDebugModalOpen, false);
|
||||
assert.equal(ctx.dom.controllerDebugModal.classList.contains('hidden'), true);
|
||||
assert.equal(disabledNotices, 1);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
function formatAxes(values: number[]): string {
|
||||
if (values.length === 0) return 'No controller axes available.';
|
||||
@@ -50,6 +51,7 @@ export function createControllerDebugModal(
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -114,8 +116,11 @@ export function createControllerDebugModal(
|
||||
: 'Connect a controller and press any button to populate raw input values.';
|
||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
||||
const activeConfig = ctx.state.controllerConfig
|
||||
? resolveControllerConfigForGamepad(ctx.state.controllerConfig, ctx.state.activeGamepadId)
|
||||
: null;
|
||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
||||
activeConfig?.buttonIndices ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +141,11 @@ export function createControllerDebugModal(
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerDebugModal(): void {
|
||||
function openControllerDebugModal(): boolean {
|
||||
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||
options.notifyControllerDisabled();
|
||||
return false;
|
||||
}
|
||||
ctx.state.controllerDebugModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
@@ -144,6 +153,7 @@ export function createControllerDebugModal(
|
||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
||||
hideToast();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeControllerDebugModal(): void {
|
||||
|
||||
@@ -158,6 +158,7 @@ function buildContext() {
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
state.connectedGamepads = [
|
||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
||||
@@ -201,6 +202,7 @@ test('controller select modal saves preferred controller from dropdown selection
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
@@ -246,6 +248,7 @@ test('controller select modal learn mode captures fresh button input and persist
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
@@ -276,6 +279,192 @@ test('controller select modal learn mode captures fresh button input and persist
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal reset control stores the default binding in the selected profile', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
if (state.controllerConfig) {
|
||||
state.controllerConfig.profiles = {
|
||||
'pad-1': {
|
||||
label: 'pad-1',
|
||||
buttonIndices: state.controllerConfig.buttonIndices,
|
||||
bindings: {
|
||||
...state.controllerConfig.bindings,
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
const right = firstRow.children[1];
|
||||
const resetButton = right.children[1];
|
||||
resetButton.dispatch('click');
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(state.controllerConfig?.profiles['pad-1']?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 0,
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal binding badge starts learn mode and persists binding', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
const right = firstRow.children[1];
|
||||
const badge = right.children[0];
|
||||
badge.dispatch('click');
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal learn mode falls back to global bindings without a controller', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
state.connectedGamepads = [];
|
||||
state.activeGamepadId = null;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
firstRow.dispatch('click');
|
||||
const editPanel = dom.controllerConfigList.children[2];
|
||||
const learnButton = editPanel.children[0].children[1].children[0];
|
||||
learnButton.dispatch('click');
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
@@ -290,6 +479,99 @@ test('controller select modal learn mode captures fresh button input and persist
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal edit control starts learn mode and persists binding', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
const saved: unknown[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async (update: unknown) => {
|
||||
saved.push(update);
|
||||
},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
modal.openControllerSelectModal();
|
||||
|
||||
const firstRow = dom.controllerConfigList.children[1];
|
||||
const right = firstRow.children[1];
|
||||
const editButton = right.children[2];
|
||||
editButton.dispatch('click');
|
||||
|
||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
||||
value: 0,
|
||||
pressed: false,
|
||||
touched: false,
|
||||
}));
|
||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
||||
modal.updateDevices();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(saved.at(-1), {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal stays closed and notifies when controller support is disabled', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
let disabledNotices = 0;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
focus: () => {},
|
||||
electronAPI: {
|
||||
saveControllerConfig: async () => {},
|
||||
notifyOverlayModalClosed: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { state, dom } = buildContext();
|
||||
if (state.controllerConfig) state.controllerConfig.enabled = false;
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {
|
||||
disabledNotices += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(modal.openControllerSelectModal(), false);
|
||||
|
||||
assert.equal(state.controllerSelectModalOpen, false);
|
||||
assert.equal(dom.controllerSelectModal.classList.contains('hidden'), true);
|
||||
assert.equal(disabledNotices, 1);
|
||||
} finally {
|
||||
domHandle.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
||||
const domHandle = installFakeDom();
|
||||
|
||||
@@ -315,6 +597,7 @@ test('controller select modal uses unique picker values for duplicate controller
|
||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
notifyControllerDisabled: () => {},
|
||||
});
|
||||
|
||||
modal.wireDomEvents();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
||||
import {
|
||||
createControllerConfigForm,
|
||||
@@ -24,6 +25,7 @@ export function createControllerSelectModal(
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
notifyControllerDisabled: () => void;
|
||||
},
|
||||
) {
|
||||
let selectedControllerKey: string | null = null;
|
||||
@@ -38,10 +40,24 @@ export function createControllerSelectModal(
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||
|
||||
function getSelectedController() {
|
||||
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
||||
}
|
||||
|
||||
function getSelectedControllerId(): string | null {
|
||||
return getSelectedController()?.id ?? null;
|
||||
}
|
||||
|
||||
function getSelectedControllerConfig() {
|
||||
const config = ctx.state.controllerConfig;
|
||||
if (!config) return null;
|
||||
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
|
||||
}
|
||||
|
||||
const controllerConfigForm = createControllerConfigForm({
|
||||
container: ctx.dom.controllerConfigList,
|
||||
getBindings: () =>
|
||||
ctx.state.controllerConfig?.bindings ?? {
|
||||
getSelectedControllerConfig()?.bindings ?? {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
||||
@@ -67,7 +83,7 @@ export function createControllerSelectModal(
|
||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||
});
|
||||
const currentBinding = config?.bindings[actionId];
|
||||
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||
const currentDpadFallback =
|
||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||
? currentBinding.dpadFallback
|
||||
@@ -216,6 +232,51 @@ export function createControllerSelectModal(
|
||||
...update.bindings,
|
||||
} as typeof ctx.state.controllerConfig.bindings;
|
||||
}
|
||||
if (update.profiles) {
|
||||
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
|
||||
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
|
||||
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
|
||||
const baseProfile = currentProfile ?? {
|
||||
label: profileUpdate.label ?? profileId,
|
||||
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
||||
bindings: ctx.state.controllerConfig.bindings,
|
||||
};
|
||||
ctx.state.controllerConfig.profiles[profileId] = {
|
||||
label: profileUpdate.label ?? baseProfile.label,
|
||||
buttonIndices: {
|
||||
...baseProfile.buttonIndices,
|
||||
...(profileUpdate.buttonIndices ?? {}),
|
||||
},
|
||||
bindings: {
|
||||
...baseProfile.bindings,
|
||||
...(profileUpdate.bindings ?? {}),
|
||||
},
|
||||
} as (typeof ctx.state.controllerConfig.profiles)[string];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBindingConfigUpdate(
|
||||
actionId: ControllerBindingKey,
|
||||
binding: ControllerBindingValue,
|
||||
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
|
||||
const selected = getSelectedController();
|
||||
if (!selected) {
|
||||
return {
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
profiles: {
|
||||
[selected.id]: {
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveBinding(
|
||||
@@ -224,11 +285,7 @@ export function createControllerSelectModal(
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
try {
|
||||
await saveControllerConfig({
|
||||
bindings: {
|
||||
[actionId]: binding,
|
||||
},
|
||||
});
|
||||
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
|
||||
learningActionId = null;
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
@@ -245,11 +302,11 @@ export function createControllerSelectModal(
|
||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
||||
): Promise<void> {
|
||||
const definition = getControllerBindingDefinition(actionId);
|
||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
||||
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
||||
const updated = { ...currentBinding, dpadFallback };
|
||||
try {
|
||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
||||
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
|
||||
dpadLearningActionId = null;
|
||||
bindingCapture = null;
|
||||
controllerConfigForm.render();
|
||||
@@ -330,7 +387,11 @@ export function createControllerSelectModal(
|
||||
}
|
||||
}
|
||||
|
||||
function openControllerSelectModal(): void {
|
||||
function openControllerSelectModal(): boolean {
|
||||
if (ctx.state.controllerConfig?.enabled !== true) {
|
||||
options.notifyControllerDisabled();
|
||||
return false;
|
||||
}
|
||||
ctx.state.controllerSelectModalOpen = true;
|
||||
syncSelectedIndexToCurrentController();
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
@@ -346,6 +407,7 @@ export function createControllerSelectModal(
|
||||
} else {
|
||||
setStatus('Choose a controller or click Learn to remap an action.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeControllerSelectModal(): void {
|
||||
@@ -387,6 +449,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -400,6 +463,7 @@ export function createControllerSelectModal(
|
||||
);
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -429,6 +493,7 @@ export function createControllerSelectModal(
|
||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||
syncSelectedControllerId();
|
||||
renderPicker();
|
||||
controllerConfigForm.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user