// 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, resolveGamepadProfile, } from '../../controls/inputs/gamepadBindings.js'; import { useGamepadHubState } from '../../controls/inputs/gamepadHub.js'; import { acquireControllerControlLock } from '../../controls/inputs/controllerRuntime.js'; import { describeController, formatControllerBinding } from '../../controls/inputs/controllerLabels.js'; import CardFrame from '../CardFrame/index.jsx'; import SliderField from './SliderField.jsx'; import { ACTIONS, NUMBER_FORMAT } from './constants.js'; import { 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 physicalInputKey(source) { /* Inversion and activation thresholds describe how an input is interpreted, not which physical control it is. Ignoring those fields ensures Axis 3 cannot silently own both a tank track and camera tilt merely because one binding happens to be inverted. */ if (source?.kind === 'axis' || source?.kind === 'axisButton') return `axis:${source.index}`; if (source?.kind === 'button' || source?.kind === 'buttonAxis') return `button:${source.index}`; return JSON.stringify(source); } function sourcesUseSamePhysicalInput(left, right) { if (!left || !right) return false; return physicalInputKey(left) === physicalInputKey(right); } function actionDriveMode(actionId) { return ACTIONS.find((action) => action.id === actionId)?.driveMode ?? null; } function CurveField({ label, value, onChange }) { return ( ); } function MappingRow({ action, source, sourceLabel, liveValue, isCapturing, onClear, onCapture, onInvert, disabled, }) { // 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}

{sourceLabel}

{/* 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 [actionFilter, setActionFilter] = useState(''); const baselineRef = useRef(null); const captureCandidateRef = useRef(null); useEffect(() => { /* The settings panel remains a live control surface so operators can tune calibration while driving and immediately feel the result. Only capture owns the controller lock: without that narrow guard, pressing the input being assigned could also drive a wheel, start a motor, or toggle rover hardware before the new binding is saved. */ if (!captureAction) return undefined; return acquireControllerControlLock('controller-binding-capture'); }, [captureAction]); const activePad = useMemo( () => pickActivePad(hubState.pads, gamepadSettings.activeInstanceKey), [hubState.pads, gamepadSettings.activeInstanceKey], ); const activeSignature = activePad?.signature ?? null; const activeProfile = useMemo(() => { const storedProfile = !activeSignature ? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT : ( gamepadSettings?.profiles?.[activeSignature] ?? gamepadSettings?.defaults?.profile ?? GAMEPAD_PROFILE_DEFAULT ); return resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT); }, [activeSignature, gamepadSettings?.defaults?.profile, gamepadSettings?.profiles]); const driveMode = activeProfile.calibration?.driveMode === 'tank' ? 'tank' : 'single'; const grouped = useMemo(() => { const query = actionFilter.trim().toLowerCase(); /* Only the active steering scheme is shown. Keeping inactive track/stick bindings out of the mapping list prevents operators from tuning controls that currently have no runtime effect. */ const modeActions = ACTIONS.filter( (action) => !action.driveMode || action.driveMode === driveMode, ); const visibleActions = query ? modeActions.filter((action) => `${action.label} ${action.section}`.toLowerCase().includes(query)) : modeActions; return groupActions(visibleActions); }, [actionFilter, driveMode]); 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 = resolveGamepadProfile(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; captureCandidateRef.current = null; }, [captureAction, activeSignature]); useEffect(() => { if (!captureAction || !activePad) return; if (!baselineRef.current) { baselineRef.current = snapshotBaseline(activePad); return; } let descriptor = buildDescriptorFromCapture(activePad, baselineRef.current, captureAction); if (captureAction.kind === 'button' && descriptor?.kind !== 'chord') { const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (descriptor && !captureCandidateRef.current) { /* Give the user a short window to add a modifier after the first button. Immediate capture makes chords physically impossible because browser frames never report both presses at precisely the same instant. */ captureCandidateRef.current = { descriptor, startedAt: now }; return; } if (!captureCandidateRef.current) return; if (now - captureCandidateRef.current.startedAt < 220) return; /* A quick tap may already be released when the chord window expires. Preserve the original candidate so capture completes normally instead of waiting for an unrelated later press. */ descriptor = captureCandidateRef.current.descriptor; } if (!descriptor) return; saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const baseProfile = resolveGamepadProfile( current.profiles?.[activeSignature] ?? current?.defaults?.profile, GAMEPAD_PROFILE_DEFAULT, ); const bindingsWithoutConflict = Object.fromEntries( Object.entries(baseProfile.bindings ?? {}).map(([actionId, binding]) => { if (actionId === captureAction.id) return [actionId, binding]; const otherMode = actionDriveMode(actionId); const captureMode = actionDriveMode(captureAction.id); /* Opposing mode-only actions may intentionally reuse a physical input because runtime never activates them together. Common actions still conflict with both modes. */ if (captureMode && otherMode && captureMode !== otherMode) return [actionId, binding]; const sources = (binding?.sources ?? []).filter( (source) => !sourcesUseSamePhysicalInput(source, descriptor), ); return [actionId, { ...binding, sources }]; }), ); const nextProfile = { ...baseProfile, bindings: { /* A physical input has one owner by default. Removing an exact duplicate avoids two toggles firing from one press while still allowing deliberate multi-button chords. */ ...bindingsWithoutConflict, [captureAction.id]: { ...(baseProfile.bindings?.[captureAction.id] ?? {}), kind: captureAction.kind, sources: [descriptor], }, }, }; return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile, }, }; }); /* Hub snapshots drive this effect, but capture state is React-owned UI state. Deferring its reset to a microtask avoids a synchronous state cascade inside the effect while the id guard prevents an older completion from cancelling a newer capture request. */ const completedActionId = captureAction.id; queueMicrotask(() => { setCaptureAction((current) => current?.id === completedActionId ? null : current); }); }, [activePad, activeSignature, captureAction, saveGamepadSettings]); const setActiveInstanceKey = useCallback( (instanceKey) => { saveGamepadSettings((prev) => ({ ...(prev ?? GAMEPAD_SETTINGS_DEFAULTS), activeInstanceKey: instanceKey || null, })); }, [saveGamepadSettings], ); const updateCalibration = useCallback( (patch) => { saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const storedProfile = (activeSignature && current.profiles?.[activeSignature]) ?? current?.defaults?.profile; const baseProfile = resolveGamepadProfile(storedProfile, 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 updateProfile = useCallback( (patch) => { saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const storedProfile = (activeSignature && current.profiles?.[activeSignature]) ?? current?.defaults?.profile; const nextProfile = { ...resolveGamepadProfile(storedProfile, GAMEPAD_PROFILE_DEFAULT), ...patch, }; if (!activeSignature) { return { ...current, defaults: { ...(current.defaults ?? {}), profile: nextProfile }, }; } return { ...current, profiles: { ...(current.profiles ?? {}), [activeSignature]: nextProfile }, }; }); }, [activeSignature, saveGamepadSettings], ); const resetActiveProfile = useCallback(() => { /* Resetting only the selected hardware avoids erasing carefully tuned profiles for other controllers. Device metadata is rebuilt so the profile remains recognizable offline. */ const nextProfile = createProfileForPad(activePad, GAMEPAD_PROFILE_DEFAULT); updateProfile(nextProfile); setCaptureAction(null); }, [activePad, updateProfile]); const updateBinding = useCallback( (actionId, updater) => { saveGamepadSettings((prev) => { const current = prev ?? GAMEPAD_SETTINGS_DEFAULTS; const storedProfile = (activeSignature && current.profiles?.[activeSignature]) ?? current?.defaults?.profile; const baseProfile = resolveGamepadProfile(storedProfile, 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]); const controllerDescription = useMemo(() => describeController(activePad), [activePad]); const liveValueForAction = useCallback((actionId) => { const outputs = diagnostics?.outputs; if (!outputs) return false; if (actionId === 'drive') return Math.hypot(outputs.driveVector.x, outputs.driveVector.y) > 0.01; if (actionId === 'tankLeft') return Math.abs(outputs.tankTracks?.left ?? 0) > 0.01; if (actionId === 'tankRight') return Math.abs(outputs.tankTracks?.right ?? 0) > 0.01; if (actionId === 'tankCameraUp' || actionId === 'tankCameraDown') { return Boolean(outputs.buttons[actionId]); } if (actionId === 'cameraTilt') return Math.abs(outputs.cameraAxis) > 0.01; if (actionId === 'mainBrush') return Math.abs(outputs.auxAxis.main) > 0.01; if (actionId === 'sideBrush') return Math.abs(outputs.auxAxis.side) > 0.01; return Boolean(outputs.buttons[actionId]); }, [diagnostics]); return ( Reset profile } bodyClassName="space-y-2 p-1 text-sm" > {captureAction && (
Release controls, then move or press the input for {captureAction.label}.
)}
Connected controller {hubState.error ? (

Controller access failed: {hubState.error}

) : hubState.pads.length === 0 ? (

{hubState.supported === false ? 'This browser does not support controllers.' : 'No controller detected. Connect it, focus this page, then press a button.'}

) : (
{activePad?.mapping ?? 'unknown'}

{controllerDescription.description ?? activePad?.id}

)}
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({ driveCurve: value })} /> updateCalibration({ baseSpeed: value })} /> updateCalibration({ turboSpeed: value })} /> {driveMode === 'single' && ( <> {/* Analog-only settings are hidden in tank mode because its two D-pad camera directions are digital velocity inputs. The values remain saved for when the operator returns to single-stick steering. */} updateCalibration({ cameraDeadzone: value })} /> updateCalibration({ cameraCurve: value })} /> )} updateCalibration({ auxDeadzone: value })} /> updateCalibration({ auxCurve: value })} /> updateCalibration({ auxSideScale: value })} /> updateCalibration({ precisionSpeed: value })} /> {driveMode === 'single' && ( )} 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 ( ); })}
))}
{!activePad ? ( null ) : (
Advanced diagnostics

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)}
)}
)}
); }