mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
feat(controller): add inline remap modal with descriptor-based bindings (#21)
This commit is contained in:
429
src/renderer/modals/controller-config-form.ts
Normal file
429
src/renderer/modals/controller-config-form.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user