Add inline controller remap modal and descriptor-based bindings

- Expand `Alt+C` from controller selection to config/remap UI with learn, clear, and reset flows
- Persist full controller binding descriptors through IPC/config (buttons, axes, directions, D-pad fallback)
- Update runtime/tests/docs/config examples for new binding schema; keep `Alt+Shift+C` debug modal
This commit is contained in:
2026-03-13 02:01:43 -07:00
parent 9eed37420e
commit ccdee0c62c
35 changed files with 2301 additions and 1417 deletions

View File

@@ -1,4 +1,10 @@
import type { ModalStateReader, RendererContext } from '../context';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import {
createControllerConfigForm,
getControllerBindingDefinition,
getDefaultControllerBinding,
} from './controller-config-form.js';
function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) {
@@ -23,6 +29,67 @@ export function createControllerSelectModal(
let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
let learningActionId: keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'] | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
const controllerConfigForm = createControllerConfigForm({
container: ctx.dom.controllerConfigList,
getBindings: () =>
ctx.state.controllerConfig?.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' },
},
getLearningActionId: () => learningActionId,
onLearn: (actionId, bindingType) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
bindingCapture.arm(
bindingType === 'axis'
? {
actionId,
bindingType: 'axis',
dpadFallback:
definition.defaultBinding.kind === 'axis' &&
'dpadFallback' in definition.defaultBinding
? definition.defaultBinding.dpadFallback
: 'none',
}
: {
actionId,
bindingType: 'discrete',
},
{
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
},
);
learningActionId = actionId;
controllerConfigForm.render();
setStatus(`Waiting for input for ${definition.label}.`);
},
onClear: (actionId) => {
void saveBinding(actionId, { kind: 'none' });
},
onReset: (actionId) => {
void saveBinding(actionId, getDefaultControllerBinding(actionId));
},
});
function getDevicesKey(): string {
return ctx.state.connectedGamepads
@@ -62,56 +129,98 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
}
function renderList(): void {
ctx.dom.controllerSelectList.innerHTML = '';
function renderPicker(): void {
ctx.dom.controllerSelectPicker.innerHTML = '';
clampSelectedIndex(ctx);
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
ctx.state.connectedGamepads.forEach((device, index) => {
const li = document.createElement('li');
li.className = 'runtime-options-list-entry';
const button = document.createElement('button');
button.type = 'button';
button.className = 'runtime-options-item runtime-options-item-button';
button.classList.toggle('active', index === ctx.state.controllerDeviceSelectedIndex);
const label = document.createElement('div');
label.className = 'runtime-options-label';
label.textContent = device.id || `Gamepad ${device.index}`;
const meta = document.createElement('div');
meta.className = 'runtime-options-value';
const tags = [
`Index ${device.index}`,
device.mapping || 'unknown mapping',
const option = document.createElement('option');
option.value = device.id;
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
`#${device.index}`,
device.mapping || 'unknown',
device.id === ctx.state.activeGamepadId ? 'active' : null,
device.id === preferredId ? 'saved' : null,
].filter(Boolean);
meta.textContent = tags.join(' · ');
button.appendChild(label);
button.appendChild(meta);
button.addEventListener('click', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
renderList();
});
button.addEventListener('dblclick', () => {
ctx.state.controllerDeviceSelectedIndex = index;
syncSelectedControllerId();
void saveSelectedController();
});
li.appendChild(button);
ctx.dom.controllerSelectList.appendChild(li);
]
.filter(Boolean)
.join(', ')})`;
ctx.dom.controllerSelectPicker.appendChild(option);
});
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
ctx.dom.controllerSelectSummary.textContent =
ctx.state.connectedGamepads.length === 0
? 'No controller detected.'
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
lastRenderedDevicesKey = getDevicesKey();
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
lastRenderedPreferredId = preferredId;
}
async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
await window.electronAPI.saveControllerConfig(update);
if (!ctx.state.controllerConfig) return;
if (update.preferredGamepadId !== undefined) {
ctx.state.controllerConfig.preferredGamepadId = update.preferredGamepadId;
}
if (update.preferredGamepadLabel !== undefined) {
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
}
if (update.bindings) {
ctx.state.controllerConfig.bindings = {
...ctx.state.controllerConfig.bindings,
...update.bindings,
} as typeof ctx.state.controllerConfig.bindings;
}
}
async function saveBinding(
actionId: keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'],
binding: NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[typeof actionId],
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
try {
await saveControllerConfig({
bindings: {
[actionId]: binding,
},
});
learningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus(`${definition?.label ?? actionId} updated.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save binding: ${message}`, true);
}
}
async function saveSelectedController(): Promise<void> {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
if (!selected) {
setStatus('No controller selected.', true);
return;
}
try {
await saveControllerConfig({
preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save preferred controller: ${message}`, true);
return;
}
syncSelectedControllerId();
renderPicker();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
}
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerId) {
@@ -127,53 +236,29 @@ export function createControllerSelectModal(
syncSelectedIndexToCurrentController();
}
if (bindingCapture && learningActionId) {
const result = bindingCapture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (result) {
void saveBinding(result.actionId as keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'], result.binding as never);
}
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderList();
renderPicker();
controllerConfigForm.render();
}
if (ctx.state.connectedGamepads.length === 0) {
if (ctx.state.connectedGamepads.length === 0 && !learningActionId) {
setStatus('No controllers detected.');
return;
}
const currentStatus = ctx.dom.controllerSelectStatus.textContent.trim();
if (
currentStatus !== 'No controller selected.' &&
!currentStatus.startsWith('Saved preferred controller:')
) {
setStatus('Select a controller to save as preferred.');
}
}
async function saveSelectedController(): Promise<void> {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
if (!selected) {
setStatus('No controller selected.', true);
return;
}
try {
await window.electronAPI.saveControllerPreference({
preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save preferred controller: ${message}`, true);
return;
}
if (ctx.state.controllerConfig) {
ctx.state.controllerConfig.preferredGamepadId = selected.id;
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
}
syncSelectedControllerId();
renderList();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
}
function openControllerSelectModal(): void {
@@ -185,16 +270,19 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
window.focus();
ctx.dom.overlay.focus({ preventScroll: true });
renderList();
renderPicker();
controllerConfigForm.render();
if (ctx.state.connectedGamepads.length === 0) {
setStatus('No controllers detected.');
} else {
setStatus('Select a controller to save as preferred.');
setStatus('Choose a controller or click Learn to remap an action.');
}
}
function closeControllerSelectModal(): void {
if (!ctx.state.controllerSelectModalOpen) return;
learningActionId = null;
bindingCapture = null;
ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
@@ -208,6 +296,13 @@ export function createControllerSelectModal(
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
if (learningActionId) {
learningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus('Controller learn mode cancelled.');
return true;
}
closeControllerSelectModal();
return true;
}
@@ -220,7 +315,7 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex + 1,
);
syncSelectedControllerId();
renderList();
renderPicker();
}
return true;
}
@@ -233,12 +328,12 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex - 1,
);
syncSelectedControllerId();
renderList();
renderPicker();
}
return true;
}
if (event.key === 'Enter') {
if (event.key === 'Enter' && !learningActionId) {
event.preventDefault();
void saveSelectedController();
return true;
@@ -254,6 +349,15 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController();
});
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
const selectedId = ctx.dom.controllerSelectPicker.value;
const selectedIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === selectedId);
if (selectedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
syncSelectedControllerId();
renderPicker();
}
});
}
return {