// Gamepad Mapping Settings Content // Purpose: Defines the Gamepad Mapping Settings Content module and the local helpers/components used in this file. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSettingsNamespace } from '../../settings/index.js'; import { GAMEPAD_PROFILE_DEFAULT, GAMEPAD_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { computeGamepadOutputs, createProfileForPad, } from '../../controls/inputs/gamepadBindings.js'; import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js'; import CardFrame from '../CardFrame/index.jsx'; import SliderField from './SliderField.jsx'; import { ACTIONS, NUMBER_FORMAT } from './constants.js'; import { formatSource, groupActions, pickActivePad, snapshotBaseline, buildDescriptorFromCapture, } from './helpers.js'; function SettingsGroupLabel({ children }) { // Section labels stay visually modest so this panel matches the rest of the app, while white // text keeps them readable without uppercase or oversized type. return

{children}

; } function MappingRow({ action, source, isCapturing, onClear, onCapture, onInvert, }) { // Mapping rows are constrained to a readable width so the source text and buttons remain // visually connected. Buttons wrap on very narrow panes instead of forcing tiny text. return (

{action.label}

{formatSource(source)}

{/* Axis-pair controls expose independent inversion because stick X/Y directions often differ between browser mappings. Keeping those buttons beside the source avoids making users hunt across a wide row while testing a binding. */} {action.kind === 'axisPair' && ( <> )} {/* Single-axis mappings only have one inversion flag, so they render the smaller control set and keep button clutter down for trigger-like bindings. */} {action.kind === 'axis' && ( )} {/* Clear and Capture are always present because they are the primary row actions. They wrap with the inversion controls on narrow panes instead of shrinking text. */}
); } export default function GamepadMappingSettings() { const hubState = useGamepadHubState(); const { value: gamepadSettings, save: saveGamepadSettings } = useSettingsNamespace( 'gamepad', GAMEPAD_SETTINGS_DEFAULTS, ); const [captureAction, setCaptureAction] = useState(null); const baselineRef = useRef(null); const grouped = useMemo(() => groupActions(ACTIONS), []); const activePad = useMemo( () => pickActivePad(hubState.pads, gamepadSettings.activeSignature), [hubState.pads, gamepadSettings.activeSignature], ); const activeSignature = activePad?.signature ?? null; const activeProfile = useMemo(() => { if (!activeSignature) { return gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; } return ( gamepadSettings?.profiles?.[activeSignature] ?? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT ); }, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]); useEffect(() => { if (!activePad || !activeSignature) return; if (gamepadSettings?.profiles?.[activeSignature]) return; saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; if (current.profiles?.[activeSignature]) return current; const base = current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const nextProfile = createProfileForPad(activePad, base); return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile, }, }; }); }, [activePad, activeSignature, gamepadSettings?.profiles, saveGamepadSettings]); useEffect(() => { baselineRef.current = null; }, [captureAction, activeSignature]); useEffect(() => { if (!captureAction || !activePad) return; if (!baselineRef.current) { baselineRef.current = snapshotBaseline(activePad); return; } const descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction); if (!descriptor) return; saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const baseProfile = current.profiles?.[activeSignature] ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const nextProfile = { ...baseProfile, bindings: { ...(baseProfile.bindings ?? {}), [captureAction.id]: { ...(baseProfile.bindings?.[captureAction.id] ?? {}), kind: captureAction.kind, sources: [descriptor], }, }, }; return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile, }, }; }); setCaptureAction(null); }, [activePad, activeSignature, captureAction, saveGamepadSettings]); const setActiveSignature = useCallback( (signature) => { saveGamepadSettings((prev) => ({ ...(prev ?? GAMEPAD_SETTINGS_DEFAULTS), activeSignature: signature || null, })); }, [saveGamepadSettings], ); const updateCalibration = useCallback( (patch) => { saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const baseProfile = (activeSignature && current.profiles?.[activeSignature]) ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const nextProfile = { ...baseProfile, calibration: { ...(baseProfile.calibration ?? {}), ...patch, }, }; if (!activeSignature) { return { ...current, defaults: { ...(current.defaults ?? {}), profile: nextProfile, }, }; } return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile, }, }; }); }, [activeSignature, saveGamepadSettings], ); const updateBinding = useCallback( (actionId, updater) => { saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const baseProfile = (activeSignature && current.profiles?.[activeSignature]) ?? current?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT; const nextBinding = updater(baseProfile.bindings?.[actionId] ?? {}); const nextProfile = { ...baseProfile, bindings: { ...(baseProfile.bindings ?? {}), [actionId]: nextBinding, }, }; if (!activeSignature) { return { ...current, defaults: { ...(current.defaults ?? {}), profile: nextProfile, }, }; } return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile, }, }; }); }, [activeSignature, saveGamepadSettings], ); const handleClear = useCallback( (action) => { updateBinding(action.id, (binding) => ({ ...binding, sources: [], })); }, [updateBinding], ); const handleInvert = useCallback( (action, axisKey) => { updateBinding(action.id, (binding) => { const sources = Array.isArray(binding.sources) ? [...binding.sources] : []; if (!sources[0]) return binding; const next = { ...sources[0] }; if (axisKey === 'invertX') { next.invertX = !next.invertX; } else if (axisKey === 'invertY') { next.invertY = !next.invertY; } else { next.invert = !next.invert; } sources[0] = next; return { ...binding, sources }; }); }, [updateBinding], ); const diagnostics = useMemo(() => { if (!activePad) return null; const outputs = computeGamepadOutputs(activePad, activeProfile); return { outputs }; }, [activePad, activeProfile]); return ( {captureAction && (

Capturing {captureAction.label}...

)}
Connected controller {hubState.pads.length === 0 ? (

No controller detected.

) : (
{activePad?.mapping ?? 'unknown'}
)}
Calibration {/* Calibration controls stay in one stacked column because range inputs become harder to tune when squeezed into multiple narrow columns. */}
updateCalibration({ driveDeadzone: value })} /> updateCalibration({ cameraDeadzone: value })} /> updateCalibration({ auxDeadzone: value })} /> updateCalibration({ auxSideScale: value })} /> updateCalibration({ cameraSensitivity: value })} />
{Object.entries(grouped).map(([section, actions]) => (
{section}
{actions.map((action) => { const binding = activeProfile.bindings?.[action.id]; const source = binding?.sources?.[0] ?? null; // The binding data is unchanged; MappingRow only changes presentation so the // existing capture, clear, and invert handlers continue to own behavior. return ( ); })}
))}
Diagnostics {!activePad ? (

No controller detected.

) : (

Raw axes

{activePad.axes.map((value, index) => ( A{index}: {NUMBER_FORMAT.format(value)} ))}

Raw buttons

{activePad.buttons.map((btn, index) => ( B{index}: {NUMBER_FORMAT.format(btn.value)} {btn.pressed ? '●' : ''} ))}
{diagnostics?.outputs && ( <>

Mapped outputs

Drive: {NUMBER_FORMAT.format(diagnostics.outputs.driveVector.x)},{' '} {NUMBER_FORMAT.format(diagnostics.outputs.driveVector.y)} Camera: {NUMBER_FORMAT.format(diagnostics.outputs.cameraAxis)} Main: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.main)} Side: {NUMBER_FORMAT.format(diagnostics.outputs.auxAxis.side)}
)}
)}
); }