// Keymap Settings // Purpose: Defines the Keymap Settings 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 { useMemo, useState, useEffect, useCallback } from 'react'; import { useControlActions, useControlSelector } from '../../controls/index.js'; import { DEFAULT_KEYMAP } from '../../controls/constants.js'; import { canonicalizeKeyInput, formatKeyLabel } from '../../controls/keymapUtils.js'; import { setKeyboardCaptureLocked } from '../../controls/inputs/keyboardCaptureLock.js'; import { useSettingsNamespace } from '../../settings/index.js'; import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import CardFrame from '../CardFrame/index.jsx'; const KEY_ACTIONS = [ { id: 'driveForward', label: 'Drive Forward', group: 'Driving' }, { id: 'driveBackward', label: 'Drive Backward', group: 'Driving' }, { id: 'driveLeft', label: 'Turn Left', group: 'Driving' }, { id: 'driveRight', label: 'Turn Right', group: 'Driving' }, { id: 'boostModifier', label: 'Boost Modifier', group: 'Driving' }, { id: 'slowModifier', label: 'Slow Modifier', group: 'Driving' }, { id: 'auxMainForward', label: 'Main Brush Forward', group: 'Aux Motors' }, { id: 'auxMainReverse', label: 'Main Brush Reverse', group: 'Aux Motors' }, { id: 'auxSideForward', label: 'Side Brush Forward', group: 'Aux Motors' }, { id: 'auxSideReverse', label: 'Side Brush Reverse', group: 'Aux Motors' }, { id: 'auxVacuumFast', label: 'Vacuum Max', group: 'Aux Motors' }, { id: 'auxVacuumSlow', label: 'Vacuum Low', group: 'Aux Motors' }, { id: 'auxAllForward', label: 'All Aux Forward', group: 'Aux Motors' }, { id: 'cameraUp', label: 'Camera Up', group: 'Camera' }, { id: 'cameraDown', label: 'Camera Down', group: 'Camera' }, { id: 'headlightToggle', label: 'Toggle Headlight', group: 'Camera' }, { id: 'laserToggle', label: 'Toggle Laser', group: 'Camera' }, { id: 'videoFilterCycle', label: 'Cycle Video Filter', group: 'Camera' }, { id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' }, { id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' }, { id: 'driveMacro', label: 'Drive Macro', group: 'Macros' }, { id: 'dockMacro', label: 'Dock Macro', group: 'Macros' }, { id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' }, { id: 'songNoteUp', label: 'Song Note Up', group: 'Audio' }, { id: 'songNoteDown', label: 'Song Note Down', group: 'Audio' }, { id: 'homeAssistantOn', label: 'Room Controls On (Cycle)', group: 'Room Controls' }, { id: 'homeAssistantOff', label: 'Room Controls Off (Cycle)', group: 'Room Controls' }, ]; function groupActions(actions) { return actions.reduce((acc, action) => { const list = acc[action.group] || (acc[action.group] = []); list.push(action); return acc; }, {}); } function clampSpeed(value, fallback) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(0, Math.min(500, num)); } const TILT_INTERVAL_MIN = 5; const TILT_INTERVAL_MAX = 500; const TILT_SPEED_MIN = 1; const TILT_SPEED_MAX = 100; function clampTiltInterval(value, fallback) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(TILT_INTERVAL_MIN, Math.min(TILT_INTERVAL_MAX, num)); } function clampTiltSpeed(value, fallback) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(TILT_SPEED_MIN, Math.min(TILT_SPEED_MAX, num)); } function mapTiltSpeedToInterval(speed) { const clampedSpeed = clampTiltSpeed(speed, speed); const ratio = (clampedSpeed - TILT_SPEED_MIN) / (TILT_SPEED_MAX - TILT_SPEED_MIN); const interval = TILT_INTERVAL_MAX - ratio * (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN); return Math.round(interval); } function mapTiltIntervalToSpeed(interval) { const clampedInterval = clampTiltInterval(interval, interval); const ratio = (TILT_INTERVAL_MAX - clampedInterval) / (TILT_INTERVAL_MAX - TILT_INTERVAL_MIN); const speed = TILT_SPEED_MIN + ratio * (TILT_SPEED_MAX - TILT_SPEED_MIN); return Math.round(speed); } function useKeyCapture(onCapture) { const [active, setActive] = useState(null); const startCapture = useCallback((actionId) => { setKeyboardCaptureLocked(true); setActive(actionId); }, []); const cancel = useCallback(() => { setKeyboardCaptureLocked(false); setActive(null); }, []); const captureFromEvent = useCallback( (actionId, event) => { event.preventDefault(); event.stopPropagation(); if (event.key === 'Escape') { cancel(); return; } const canonical = canonicalizeKeyInput(event.key ?? ''); if (!canonical) return; onCapture(actionId, canonical, event); cancel(); }, [cancel, onCapture], ); useEffect(() => () => setKeyboardCaptureLocked(false), []); return { active, startCapture, cancel, captureFromEvent }; } function SettingsGroupLabel({ children }) { // Group labels stay at the app's normal small-panel scale, but use white text so they are // readable without resorting to oversized type or all-caps styling. return
{children}
; } function SpeedField({ label, description, value, onChange, min = 0, max = 500, step = 5 }) { return ( ); } function BindingRow({ action, value, isActive, onCaptureStart, onCancel, onCaptureKeyDown }) { // Binding rows are intentionally constrained and boxed: the action name, current key, and // change button are one interaction unit, so proximity matters more than stretching to fill // every available pixel in the surrounding sidebar. return ({action.label}
{formatKeyLabel(value)}