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 = { 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 = { 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 = { 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 }; }