feat(controller): add inline remap modal with descriptor-based bindings (#21)

This commit is contained in:
2026-03-15 15:55:45 -07:00
committed by GitHub
parent 9eed37420e
commit 478869ff28
38 changed files with 3136 additions and 1431 deletions

View File

@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerConfigForm } from './controller-config-form.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return tokens.has(entry);
}
if (force) tokens.add(entry);
else tokens.delete(entry);
return force;
},
contains: (entry: string) => tokens.has(entry),
};
}
function createFakeElement() {
const attributes = new Map<string, string>();
const el = {
className: '',
textContent: '',
_innerHTML: '',
value: '',
disabled: false,
selected: false,
type: '',
children: [] as any[],
listeners: new Map<string, Array<(e?: any) => void>>(),
classList: createClassList(),
appendChild(child: any) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (e?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
dispatch(type: string) {
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
for (const listener of this.listeners.get(type) ?? []) {
listener(fakeEvent);
}
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
};
Object.defineProperty(el, 'innerHTML', {
get() {
return el._innerHTML;
},
set(v: string) {
el._innerHTML = v;
if (v === '') el.children.length = 0;
},
});
return el;
}
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
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: () => 'toggleLookup',
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();
// In the new compact list layout, children are:
// [0] group header, [1] first binding row (auto-expanded because learning), [2] edit panel, [3] next row, ...
const firstRow = container.children[1];
assert.equal(firstRow.classList.contains('expanded'), true);
// After expanding, the edit panel is inserted after the row:
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
const editPanel = container.children[2];
// editPanel > inner > actions > learnButton
const inner = editPanel.children[0];
const actions = inner.children[1];
const learnButton = actions.children[0];
learnButton.dispatch('click');
actions.children[1].dispatch('click');
actions.children[2].dispatch('click');
assert.deepEqual(calls, [
'learn:toggleLookup:discrete',
'clear:toggleLookup',
'reset:toggleLookup',
]);
} finally {
if (previousDocumentDescriptor) {
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'document');
}
}
});

View File

@@ -0,0 +1,429 @@
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
type ControllerBindingActionId = keyof ResolvedControllerConfig['bindings'];
type ControllerBindingDefinition = {
id: ControllerBindingActionId;
label: string;
group: string;
bindingType: 'discrete' | 'axis';
defaultBinding: ResolvedControllerConfig['bindings'][ControllerBindingActionId];
};
export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
{
id: 'toggleLookup',
label: 'Toggle Lookup',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 0 },
},
{
id: 'closeLookup',
label: 'Close Lookup',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 1 },
},
{
id: 'mineCard',
label: 'Mine Card',
group: 'Lookup',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 2 },
},
{
id: 'toggleKeyboardOnlyMode',
label: 'Toggle Keyboard-Only Mode',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 3 },
},
{
id: 'toggleMpvPause',
label: 'Toggle MPV Pause',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 9 },
},
{
id: 'quitMpv',
label: 'Quit MPV',
group: 'Playback',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 6 },
},
{
id: 'previousAudio',
label: 'Previous Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'none' },
},
{
id: 'nextAudio',
label: 'Next Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 5 },
},
{
id: 'playCurrentAudio',
label: 'Play Current Audio',
group: 'Popup Audio',
bindingType: 'discrete',
defaultBinding: { kind: 'button', buttonIndex: 4 },
},
{
id: 'leftStickHorizontal',
label: 'Token Move',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
},
{
id: 'leftStickVertical',
label: 'Popup Scroll',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
},
{
id: 'rightStickHorizontal',
label: 'Alt Horizontal',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
},
{
id: 'rightStickVertical',
label: 'Popup Jump',
group: 'Navigation',
bindingType: 'axis',
defaultBinding: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
];
export function getControllerBindingDefinition(actionId: ControllerBindingActionId) {
return CONTROLLER_BINDING_DEFINITIONS.find((definition) => definition.id === actionId) ?? null;
}
export function getDefaultControllerBinding(actionId: ControllerBindingActionId) {
const definition = getControllerBindingDefinition(actionId);
if (!definition) {
return { kind: 'none' } as const;
}
return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
}
export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback {
const definition = getControllerBindingDefinition(actionId);
if (!definition || definition.defaultBinding.kind !== 'axis') return 'none';
const binding = definition.defaultBinding;
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
}
const STANDARD_BUTTON_NAMES: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
2: 'X / Square',
3: 'Y / Triangle',
4: 'LB / L1',
5: 'RB / R1',
6: 'Back / Select',
7: 'Start / Options',
8: 'L3 / LS',
9: 'R3 / RS',
10: 'Left Stick Click',
11: 'Right Stick Click',
12: 'D-pad Up',
13: 'D-pad Down',
14: 'D-pad Left',
15: 'D-pad Right',
16: 'Guide / Home',
};
const STANDARD_AXIS_NAMES: Record<number, string> = {
0: 'Left Stick X',
1: 'Left Stick Y',
2: 'Left Trigger',
3: 'Right Stick X',
4: 'Right Stick Y',
5: 'Right Trigger',
};
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
none: 'None',
horizontal: 'D-pad \u2194',
vertical: 'D-pad \u2195',
};
function getFriendlyButtonName(buttonIndex: number): string {
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`;
}
function getFriendlyAxisName(axisIndex: number): string {
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`;
}
export function formatControllerBindingSummary(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
): string {
if (binding.kind === 'none') {
return 'Disabled';
}
if ('direction' in binding) {
return `Axis ${binding.axisIndex} ${binding.direction === 'positive' ? '+' : '-'}`;
}
if ('buttonIndex' in binding) {
return `Button ${binding.buttonIndex}`;
}
if (binding.dpadFallback === 'none') {
return `Axis ${binding.axisIndex}`;
}
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
}
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string {
if (binding.kind === 'none') return 'None';
return getFriendlyAxisName(binding.axisIndex);
}
function formatFriendlyBindingLabel(
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
): string {
if (binding.kind === 'none') return 'None';
if ('direction' in binding) {
const name = getFriendlyAxisName(binding.axisIndex);
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
}
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex);
return getFriendlyAxisName(binding.axisIndex);
}
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
type ExpandedRowKey = string;
export function createControllerConfigForm(options: {
container: HTMLElement;
getBindings: () => ResolvedControllerConfig['bindings'];
getLearningActionId: () => ControllerBindingActionId | null;
getDpadLearningActionId: () => ControllerBindingActionId | null;
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
onClear: (actionId: ControllerBindingActionId) => void;
onReset: (actionId: ControllerBindingActionId) => void;
onDpadLearn: (actionId: ControllerBindingActionId) => void;
onDpadClear: (actionId: ControllerBindingActionId) => void;
onDpadReset: (actionId: ControllerBindingActionId) => void;
}) {
let expandedRowKey: ExpandedRowKey | null = null;
function render(): void {
options.container.innerHTML = '';
let lastGroup = '';
const learningActionId = options.getLearningActionId();
const dpadLearningActionId = options.getDpadLearningActionId();
// Auto-expand when learning starts
if (learningActionId) {
expandedRowKey = learningActionId;
} else if (dpadLearningActionId) {
expandedRowKey = `${dpadLearningActionId}:dpad`;
}
for (const definition of CONTROLLER_BINDING_DEFINITIONS) {
if (definition.group !== lastGroup) {
const header = document.createElement('div');
header.className = 'controller-config-group';
header.textContent = definition.group;
options.container.appendChild(header);
lastGroup = definition.group;
}
const binding = options.getBindings()[definition.id];
if (definition.bindingType === 'axis') {
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId);
} else {
renderDiscreteRow(definition, binding, learningActionId);
}
}
}
function renderDiscreteRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
learningActionId: ControllerBindingActionId | null,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const hint = isLearning
? 'Press a button, trigger, or move a stick\u2026'
: `Currently: ${formatControllerBindingSummary(binding)}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function renderAxisStickRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
learningActionId: ControllerBindingActionId | null,
): void {
const rowKey = definition.id as string;
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function renderAxisDpadRow(
definition: ControllerBindingDefinition,
binding: ResolvedControllerAxisBinding,
dpadLearningActionId: ControllerBindingActionId | null,
): void {
const rowKey = `${definition.id as string}:dpad`;
const isExpanded = expandedRowKey === rowKey;
const isLearning = dpadLearningActionId === definition.id;
const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback;
const badgeText = DPAD_FALLBACK_LABELS[dpadFallback];
const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
});
options.container.appendChild(row);
if (isExpanded) {
const hint = isLearning
? 'Press a D-pad direction\u2026'
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); },
onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); },
});
options.container.appendChild(panel);
}
}
function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement {
const row = document.createElement('div');
row.className = 'controller-config-row';
if (isExpanded) row.classList.add('expanded');
const label = document.createElement('div');
label.className = 'controller-config-label';
label.textContent = labelText;
const right = document.createElement('div');
right.className = 'controller-config-right';
const badge = document.createElement('span');
badge.className = 'controller-config-badge';
if (isDisabled) badge.classList.add('disabled');
badge.textContent = badgeText;
const editIcon = document.createElement('span');
editIcon.className = 'controller-config-edit-icon';
editIcon.textContent = '\u270E';
right.appendChild(badge);
right.appendChild(editIcon);
row.appendChild(label);
row.appendChild(right);
return row;
}
function createEditPanel(
hintText: string,
isLearning: boolean,
callbacks: {
onLearn: (e: Event) => void;
onClear: (e: Event) => void;
onReset: (e: Event) => void;
},
): HTMLDivElement {
const panel = document.createElement('div');
panel.className = 'controller-config-edit-panel';
const inner = document.createElement('div');
inner.className = 'controller-config-edit-inner';
const hint = document.createElement('div');
hint.className = 'controller-config-edit-hint';
if (isLearning) hint.classList.add('learning');
hint.textContent = hintText;
const actions = document.createElement('div');
actions.className = 'controller-config-edit-actions';
const learnButton = document.createElement('button');
learnButton.type = 'button';
learnButton.className = isLearning ? 'btn-learn active' : 'btn-learn';
learnButton.textContent = isLearning ? 'Listening\u2026' : 'Learn';
learnButton.addEventListener('click', callbacks.onLearn);
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'btn-secondary';
clearButton.textContent = 'Clear';
clearButton.addEventListener('click', callbacks.onClear);
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'btn-secondary';
resetButton.textContent = 'Reset';
resetButton.addEventListener('click', callbacks.onReset);
actions.appendChild(learnButton);
actions.appendChild(clearButton);
actions.appendChild(resetButton);
inner.appendChild(hint);
inner.appendChild(actions);
panel.appendChild(inner);
return panel;
}
return { render };
}

View File

@@ -62,19 +62,19 @@ test('controller debug modal renders active controller axes, buttons, and config
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
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' },
},
};
@@ -175,19 +175,19 @@ test('controller debug modal copies buttonIndices config to clipboard', async ()
rightTrigger: 7,
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
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' },
},
};

View File

@@ -27,121 +27,185 @@ function createClassList(initialTokens: string[] = []) {
};
}
test('controller select modal saves the selected preferred controller', async () => {
function createFakeElement() {
const attributes = new Map<string, string>();
const el = {
className: '',
textContent: '',
_innerHTML: '',
value: '',
disabled: false,
selected: false,
type: '',
children: [] as any[],
listeners: new Map<string, Array<(e?: any) => void>>(),
classList: createClassList(),
appendChild(child: any) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (e?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
dispatch(type: string) {
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
for (const listener of this.listeners.get(type) ?? []) {
listener(fakeEvent);
}
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
querySelector(selector: string) {
const match = selector.match(/^\[data-testid="(.+)"\]$/);
if (!match) return null;
const testId = match[1];
for (const child of el.children) {
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
return child;
}
if (typeof child.querySelector === 'function') {
const nested = child.querySelector(selector);
if (nested) return nested;
}
}
return null;
},
focus: () => {},
};
Object.defineProperty(el, 'innerHTML', {
get() {
return el._innerHTML;
},
set(v: string) {
el._innerHTML = v;
if (v === '') el.children.length = 0;
},
});
return el;
}
function installFakeDom() {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const saved: Array<{ preferredGamepadId: string; preferredGamepadLabel: string }> = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
});
return {
restore: () => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
},
};
}
function buildContext() {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
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' },
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const dom = {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
controllerSelectClose: createFakeElement(),
controllerSelectPicker: createFakeElement(),
controllerSelectSummary: createFakeElement(),
controllerConfigList: createFakeElement(),
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectSave: createFakeElement(),
};
return { state, dom };
}
test('controller select modal saves preferred controller from dropdown selection', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async (update: {
preferredGamepadId: string;
preferredGamepadLabel: string;
}) => {
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const overlayClassList = createClassList();
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-2',
preferredGamepadLabel: 'pad-2',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'leftShoulder',
nextAudio: 'rightShoulder',
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: overlayClassList, focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
state.controllerDeviceSelectedIndex = 1;
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await Promise.resolve();
assert.deepEqual(saved, [
{
@@ -150,578 +214,114 @@ test('controller select modal saves the selected preferred controller', async ()
},
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
domHandle.restore();
}
});
test('controller select modal preserves manual selection while controller polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
test('controller select modal learn mode captures fresh button input and persists binding', async () => {
const domHandle = installFakeDom();
const saved: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
saveControllerConfig: async (update: unknown) => {
saved.push(update);
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
const { state, dom } = buildContext();
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 0);
modal.handleControllerSelectKeydown({
key: 'ArrowDown',
preventDefault: () => {},
} as KeyboardEvent);
assert.equal(state.controllerDeviceSelectedIndex, 1);
// In the new compact list layout, children are:
// [0] group header, [1] first binding row, [2] second binding row, ...
// Click the row to expand the inline edit panel
const firstRow = dom.controllerConfigList.children[1];
firstRow.dispatch('click');
// After expanding, the edit panel is inserted after the row:
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
const editPanel = dom.controllerConfigList.children[2];
// editPanel > inner > actions > learnButton
const inner = editPanel.children[0];
const actions = inner.children[1];
const learnButton = actions.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 },
},
});
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
} finally {
domHandle.restore();
}
});
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
const domHandle = installFakeDom();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerConfig: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
try {
const { state, dom } = buildContext();
state.connectedGamepads = [
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'same-pad';
const modal = createControllerSelectModal({ state, dom } as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.wireDomEvents();
modal.openControllerSelectModal();
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
assert.notEqual(firstOption.value, secondOption.value);
dom.controllerSelectPicker.value = secondOption.value;
dom.controllerSelectPicker.dispatch('change');
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal prefers active controller over saved preferred controller', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
assert.equal(state.controllerDeviceSelectedIndex, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal preserves saved status across polling updates', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
modal.updateDevices();
assert.match(ctx.dom.controllerSelectStatus.textContent, /Saved preferred controller/);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal surfaces save errors without mutating saved preference', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {
throw new Error('disk write failed');
},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [{ id: 'pad-2', index: 1, mapping: 'standard', connected: true }];
state.activeGamepadId = 'pad-2';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
await modal.handleControllerSelectKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
assert.match(ctx.dom.controllerSelectStatus.textContent, /Failed to save preferred controller/);
assert.equal(state.controllerConfig.preferredGamepadId, 'pad-1');
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('controller select modal does not rerender unchanged device snapshots every poll', () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
let appendCount = 0;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
focus: () => {},
electronAPI: {
saveControllerPreference: async () => {},
notifyOverlayModalClosed: () => {},
},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => ({
className: '',
textContent: '',
classList: createClassList(),
appendChild: () => {},
addEventListener: () => {},
}),
},
});
try {
const state = createRendererState();
state.controllerConfig = {
enabled: true,
preferredGamepadId: 'pad-1',
preferredGamepadLabel: 'pad-1',
smoothScroll: true,
scrollPixelsPerSecond: 960,
horizontalJumpPixels: 160,
stickDeadzone: 0.2,
triggerInputMode: 'auto',
triggerDeadzone: 0.5,
repeatDelayMs: 220,
repeatIntervalMs: 80,
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: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
},
};
state.connectedGamepads = [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
];
state.activeGamepadId = 'pad-1';
const ctx = {
dom: {
overlay: { classList: createClassList(), focus: () => {} },
controllerSelectModal: {
classList: createClassList(['hidden']),
setAttribute: () => {},
},
controllerSelectClose: { addEventListener: () => {} },
controllerSelectHint: { textContent: '' },
controllerSelectStatus: { textContent: '', classList: createClassList() },
controllerSelectList: {
innerHTML: '',
appendChild: () => {
appendCount += 1;
},
},
controllerSelectSave: { addEventListener: () => {} },
},
state,
};
const modal = createControllerSelectModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
modal.openControllerSelectModal();
const initialAppendCount = appendCount;
modal.updateDevices();
assert.equal(appendCount, initialAppendCount);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
domHandle.restore();
}
});

View File

@@ -1,4 +1,11 @@
import type { ModalStateReader, RendererContext } from '../context';
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
import {
createControllerConfigForm,
getControllerBindingDefinition,
getDefaultControllerBinding,
getDefaultDpadFallback,
} from './controller-config-form.js';
function clampSelectedIndex(ctx: RendererContext): void {
if (ctx.state.connectedGamepads.length === 0) {
@@ -19,10 +26,104 @@ export function createControllerSelectModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let selectedControllerId: string | null = null;
let selectedControllerKey: string | null = null;
let lastRenderedDevicesKey = '';
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
type ControllerBindingValue =
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | 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,
getDpadLearningActionId: () => dpadLearningActionId,
onLearn: (actionId, bindingType) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
dpadLearningActionId = null;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
const currentBinding = config?.bindings[actionId];
const currentDpadFallback =
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
? currentBinding.dpadFallback
: 'none';
bindingCapture.arm(
bindingType === 'axis'
? {
actionId,
bindingType: 'axis',
dpadFallback: currentDpadFallback,
}
: {
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));
},
onDpadLearn: (actionId) => {
const definition = getControllerBindingDefinition(actionId);
if (!definition) return;
learningActionId = null;
const config = ctx.state.controllerConfig;
bindingCapture = createControllerBindingCapture({
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
stickDeadzone: config?.stickDeadzone ?? 0.2,
});
bindingCapture.arm(
{ actionId, bindingType: 'dpad' },
{
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
},
);
dpadLearningActionId = actionId;
controllerConfigForm.render();
setStatus(`Press a D-pad direction for ${definition.label}.`);
},
onDpadClear: (actionId) => {
void saveDpadFallback(actionId, 'none');
},
onDpadReset: (actionId) => {
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
},
});
function getDevicesKey(): string {
return ctx.state.connectedGamepads
@@ -30,9 +131,13 @@ export function createControllerSelectModal(
.join('||');
}
function getDeviceSelectionKey(device: { id: string; index: number }): string {
return `${device.id}:${device.index}`;
}
function syncSelectedControllerId(): void {
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
selectedControllerId = selected?.id ?? null;
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
}
function syncSelectedIndexToCurrentController(): void {
@@ -62,90 +167,93 @@ 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 = getDeviceSelectionKey(device);
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;
}
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerId) {
const preservedIndex = ctx.state.connectedGamepads.findIndex(
(device) => device.id === selectedControllerId,
);
if (preservedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
} else {
syncSelectedIndexToCurrentController();
}
} else {
syncSelectedIndexToCurrentController();
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;
}
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderList();
async function saveBinding(
actionId: ControllerBindingKey,
binding: ControllerBindingValue,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
try {
await saveControllerConfig({
bindings: {
[actionId]: binding,
},
});
learningActionId = null;
dpadLearningActionId = 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);
}
}
if (ctx.state.connectedGamepads.length === 0) {
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 saveDpadFallback(
actionId: ControllerBindingKey,
dpadFallback: import('../../types').ControllerDpadFallback,
): Promise<void> {
const definition = getControllerBindingDefinition(actionId);
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
if (!currentBinding || currentBinding.kind !== 'axis') return;
const updated = { ...currentBinding, dpadFallback };
try {
await saveControllerConfig({ bindings: { [actionId]: updated } });
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus(`${definition?.label ?? actionId} D-pad updated.`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setStatus(`Failed to save D-pad binding: ${message}`, true);
}
}
@@ -157,7 +265,7 @@ export function createControllerSelectModal(
}
try {
await window.electronAPI.saveControllerPreference({
await saveControllerConfig({
preferredGamepadId: selected.id,
preferredGamepadLabel: selected.id,
});
@@ -167,15 +275,55 @@ export function createControllerSelectModal(
return;
}
if (ctx.state.controllerConfig) {
ctx.state.controllerConfig.preferredGamepadId = selected.id;
ctx.state.controllerConfig.preferredGamepadLabel = selected.id;
}
syncSelectedControllerId();
renderList();
renderPicker();
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
}
function updateDevices(): void {
if (!ctx.state.controllerSelectModalOpen) return;
if (selectedControllerKey) {
const preservedIndex = ctx.state.connectedGamepads.findIndex(
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
);
if (preservedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
} else {
syncSelectedIndexToCurrentController();
}
} else {
syncSelectedIndexToCurrentController();
}
if (bindingCapture && (learningActionId || dpadLearningActionId)) {
const result = bindingCapture.poll({
axes: ctx.state.controllerRawAxes,
buttons: ctx.state.controllerRawButtons,
});
if (result) {
if (result.bindingType === 'dpad') {
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
} else {
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
}
}
}
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
const shouldRender =
getDevicesKey() !== lastRenderedDevicesKey ||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
preferredId !== lastRenderedPreferredId;
if (shouldRender) {
renderPicker();
controllerConfigForm.render();
}
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
setStatus('No controllers detected.');
}
}
function openControllerSelectModal(): void {
ctx.state.controllerSelectModalOpen = true;
syncSelectedIndexToCurrentController();
@@ -185,16 +333,20 @@ 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;
dpadLearningActionId = null;
bindingCapture = null;
ctx.state.controllerSelectModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.controllerSelectModal.classList.add('hidden');
@@ -208,6 +360,14 @@ export function createControllerSelectModal(
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
if (event.key === 'Escape') {
event.preventDefault();
if (learningActionId || dpadLearningActionId) {
learningActionId = null;
dpadLearningActionId = null;
bindingCapture = null;
controllerConfigForm.render();
setStatus('Controller learn mode cancelled.');
return true;
}
closeControllerSelectModal();
return true;
}
@@ -220,7 +380,7 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex + 1,
);
syncSelectedControllerId();
renderList();
renderPicker();
}
return true;
}
@@ -233,12 +393,12 @@ export function createControllerSelectModal(
ctx.state.controllerDeviceSelectedIndex - 1,
);
syncSelectedControllerId();
renderList();
renderPicker();
}
return true;
}
if (event.key === 'Enter') {
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
event.preventDefault();
void saveSelectedController();
return true;
@@ -254,6 +414,17 @@ export function createControllerSelectModal(
ctx.dom.controllerSelectSave.addEventListener('click', () => {
void saveSelectedController();
});
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
const selectedKey = ctx.dom.controllerSelectPicker.value;
const selectedIndex = ctx.state.connectedGamepads.findIndex(
(device) => getDeviceSelectionKey(device) === selectedKey,
);
if (selectedIndex >= 0) {
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
syncSelectedControllerId();
renderPicker();
}
});
}
return {