mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
controls
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import AuthPanel from './AuthPanel.jsx';
|
||||
import AdminPanel from './AdminPanel.jsx';
|
||||
import KeymapSettings from './KeymapSettings.jsx';
|
||||
|
||||
const manualTabs = [
|
||||
{ key: 'start', label: 'Start OI' },
|
||||
@@ -33,6 +34,7 @@ export default function AdvancedSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<KeymapSettings />
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Manual OI commands</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useControlSystem } from '../controls/index.js';
|
||||
import { DEFAULT_KEYMAP } from '../controls/constants.js';
|
||||
import { canonicalizeKeyInput, formatKeyLabel } from '../controls/keymapUtils.js';
|
||||
|
||||
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: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
|
||||
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
|
||||
];
|
||||
|
||||
function groupActions(actions) {
|
||||
return actions.reduce((acc, action) => {
|
||||
const list = acc[action.group] || (acc[action.group] = []);
|
||||
list.push(action);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function useKeyCapture(onCapture) {
|
||||
const [active, setActive] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
function handle(event) {
|
||||
event.preventDefault();
|
||||
if (event.key === 'Escape') {
|
||||
setActive(null);
|
||||
return;
|
||||
}
|
||||
const canonical = canonicalizeKeyInput(event.key ?? '');
|
||||
if (canonical) {
|
||||
onCapture(active, canonical, event);
|
||||
setActive(null);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handle, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handle, { capture: true });
|
||||
}, [active, onCapture]);
|
||||
|
||||
return { active, startCapture: setActive, cancel: () => setActive(null) };
|
||||
}
|
||||
|
||||
export default function KeymapSettings() {
|
||||
const {
|
||||
state: { keymap },
|
||||
actions: { updateKeyBinding, resetKeyBindings },
|
||||
} = useControlSystem();
|
||||
const grouped = useMemo(() => groupActions(KEY_ACTIONS), []);
|
||||
const { active, startCapture, cancel } = useKeyCapture((actionId, value) => {
|
||||
updateKeyBinding(actionId, value);
|
||||
});
|
||||
|
||||
const currentKey = useCallback(
|
||||
(id) => keymap?.[id]?.[0] || DEFAULT_KEYMAP[id]?.[0] || '',
|
||||
[keymap],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Keyboard Layout</p>
|
||||
<p className="text-[0.7rem] text-slate-500">Per-browser · click to change a binding</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetKeyBindings()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(grouped).map(([group, actions]) => (
|
||||
<div key={group} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-400">{group}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{actions.map((action) => {
|
||||
const value = currentKey(action.id);
|
||||
const isActive = active === action.id;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancel()}
|
||||
className="rounded bg-red-600 px-2 py-1 text-[0.65rem] uppercase tracking-wide text-red-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCapture(action.id)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${isActive ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
>
|
||||
{isActive ? 'Press a key…' : 'Change'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,17 @@ import { controlReducer, initialControlState } from './controlReducer.js';
|
||||
import { computeDifferentialSpeeds, clamp } from './controlMath.js';
|
||||
import { useCommandPipeline } from './commandPipeline.js';
|
||||
import { loadControlSettings, saveControlSettings } from './persistence.js';
|
||||
import { DEFAULT_KEYMAP } from './constants.js';
|
||||
import { canonicalizeKeyInput } from './keymapUtils.js';
|
||||
|
||||
const ControlSystemContext = createContext(null);
|
||||
|
||||
function cloneKeymap(map) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(map || {}).map(([key, values]) => [key, Array.isArray(values) ? [...values] : []]),
|
||||
);
|
||||
}
|
||||
|
||||
function clampServoAngle(config, value) {
|
||||
if (!config) return value;
|
||||
const min = typeof config.minAngle === 'number' ? config.minAngle : -45;
|
||||
@@ -90,6 +98,26 @@ export function ControlSystemProvider({ children }) {
|
||||
[pipeline],
|
||||
);
|
||||
|
||||
const updateKeyBinding = useCallback(
|
||||
(bindingId, keyValue) => {
|
||||
if (!bindingId) return false;
|
||||
const canonical = canonicalizeKeyInput(keyValue);
|
||||
if (!canonical) return false;
|
||||
const next = cloneKeymap(state.keymap);
|
||||
next[bindingId] = [canonical];
|
||||
dispatch({ type: 'control/set-keymap', payload: next });
|
||||
persistSettings({ keymap: next });
|
||||
return true;
|
||||
},
|
||||
[state.keymap, persistSettings],
|
||||
);
|
||||
|
||||
const resetKeyBindings = useCallback(() => {
|
||||
const defaults = cloneKeymap(DEFAULT_KEYMAP);
|
||||
dispatch({ type: 'control/set-keymap', payload: defaults });
|
||||
persistSettings({ keymap: defaults });
|
||||
}, [persistSettings]);
|
||||
|
||||
const setServoAngle = useCallback(
|
||||
(value) => {
|
||||
if (!pipeline.servoConfig) return;
|
||||
@@ -212,6 +240,8 @@ export function ControlSystemProvider({ children }) {
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
reloadSettings,
|
||||
persistSettings,
|
||||
@@ -230,6 +260,8 @@ export function ControlSystemProvider({ children }) {
|
||||
stopAllMotion,
|
||||
sendOiCommand,
|
||||
setSensorStream,
|
||||
updateKeyBinding,
|
||||
resetKeyBindings,
|
||||
registerInputState,
|
||||
reloadSettings,
|
||||
persistSettings,
|
||||
|
||||
@@ -1,37 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useControlSystem } from '../ControlContext.jsx';
|
||||
import { normalizeKeymapEntries, tokensForEvent } from '../keymapUtils.js';
|
||||
|
||||
const SOURCE = 'keyboard';
|
||||
const ZERO_VECTOR = { x: 0, y: 0, boost: false };
|
||||
const ZERO_AUX = { main: 0, side: 0, vacuum: 0 };
|
||||
const SERVO_REPEAT_MS = 110;
|
||||
const KEY_ALIASES = {
|
||||
'{': '[',
|
||||
'}': ']',
|
||||
':': ';',
|
||||
'"': "'",
|
||||
'<': ',',
|
||||
'>': '.',
|
||||
'?': '/',
|
||||
'|': '\\',
|
||||
'_': '-',
|
||||
'+': '=',
|
||||
};
|
||||
const BINDING_CODE_MAP = {
|
||||
'[': 'BracketLeft',
|
||||
']': 'BracketRight',
|
||||
'\\': 'Backslash',
|
||||
';': 'Semicolon',
|
||||
"'": 'Quote',
|
||||
',': 'Comma',
|
||||
'.': 'Period',
|
||||
'/': 'Slash',
|
||||
'-': 'Minus',
|
||||
'=': 'Equal',
|
||||
'`': 'Backquote',
|
||||
};
|
||||
const CODE_PREFIX = 'code:';
|
||||
const KEY_PREFIX = 'key:';
|
||||
|
||||
function shouldIgnoreEvent(event) {
|
||||
const target = event.target;
|
||||
@@ -45,59 +19,6 @@ function shouldIgnoreEvent(event) {
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalizeValue(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const lower = value.toLowerCase();
|
||||
return KEY_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
function deriveCodeForValue(value) {
|
||||
if (!value) return null;
|
||||
if (BINDING_CODE_MAP[value]) return BINDING_CODE_MAP[value];
|
||||
if (value.length === 1) {
|
||||
if (/[a-z]/.test(value)) return `Key${value.toUpperCase()}`;
|
||||
if (/[0-9]/.test(value)) return `Digit${value}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeKeyToken(value) {
|
||||
const canonical = canonicalizeValue(value);
|
||||
return canonical ? `${KEY_PREFIX}${canonical}` : null;
|
||||
}
|
||||
|
||||
function makeCodeToken(value) {
|
||||
const canonical = canonicalizeValue(value);
|
||||
const code = deriveCodeForValue(canonical);
|
||||
return code ? `${CODE_PREFIX}${code}` : null;
|
||||
}
|
||||
|
||||
function tokensFromEvent(event) {
|
||||
const tokens = new Set();
|
||||
const keyToken = makeKeyToken(event?.key ?? '');
|
||||
if (keyToken) tokens.add(keyToken);
|
||||
const code = event?.code;
|
||||
if (code) {
|
||||
tokens.add(`${CODE_PREFIX}${code}`);
|
||||
}
|
||||
return Array.from(tokens);
|
||||
}
|
||||
|
||||
function normalizeKeymap(keymap = {}) {
|
||||
const entries = Object.entries(keymap).map(([action, bindings]) => {
|
||||
const values = Array.isArray(bindings) ? bindings : [bindings];
|
||||
const normalized = new Set();
|
||||
values.forEach((value) => {
|
||||
const keyToken = makeKeyToken(String(value));
|
||||
if (keyToken) normalized.add(keyToken);
|
||||
const codeToken = makeCodeToken(String(value));
|
||||
if (codeToken) normalized.add(codeToken);
|
||||
});
|
||||
return [action, normalized];
|
||||
});
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function bindingActive(bindingSet, keys) {
|
||||
if (!bindingSet || bindingSet.size === 0) return false;
|
||||
for (const key of keys) {
|
||||
@@ -166,7 +87,7 @@ export default function KeyboardInputManager() {
|
||||
registerInputState,
|
||||
},
|
||||
} = useControlSystem();
|
||||
const keymap = useMemo(() => normalizeKeymap(state.keymap), [state.keymap]);
|
||||
const keymap = useMemo(() => normalizeKeymapEntries(state.keymap), [state.keymap]);
|
||||
const actionTokens = useMemo(() => {
|
||||
const tokens = new Set();
|
||||
Object.values(keymap).forEach((bindingSet) => {
|
||||
@@ -271,7 +192,7 @@ export default function KeyboardInputManager() {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event) {
|
||||
if (shouldIgnoreEvent(event)) return;
|
||||
const tokens = tokensFromEvent(event);
|
||||
const tokens = tokensForEvent(event);
|
||||
if (tokens.length === 0) return;
|
||||
if (tokens.some((token) => actionTokens.has(token))) {
|
||||
event.preventDefault();
|
||||
@@ -296,7 +217,7 @@ export default function KeyboardInputManager() {
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
const tokens = tokensFromEvent(event);
|
||||
const tokens = tokensForEvent(event);
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[keyboard] keyup', { key: event.key, code: event.code, tokens });
|
||||
tokens.forEach((token) => activeTokensRef.current.delete(token));
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const KEY_ALIASES = {
|
||||
'{': '[',
|
||||
'}': ']',
|
||||
':': ';',
|
||||
'"': "'",
|
||||
'<': ',',
|
||||
'>': '.',
|
||||
'?': '/',
|
||||
'|': '\\',
|
||||
'_': '-',
|
||||
'+': '=',
|
||||
};
|
||||
|
||||
const BINDING_CODE_MAP = {
|
||||
'[': 'BracketLeft',
|
||||
']': 'BracketRight',
|
||||
'\\': 'Backslash',
|
||||
';': 'Semicolon',
|
||||
"'": 'Quote',
|
||||
',': 'Comma',
|
||||
'.': 'Period',
|
||||
'/': 'Slash',
|
||||
'-': 'Minus',
|
||||
'=': 'Equal',
|
||||
'`': 'Backquote',
|
||||
};
|
||||
|
||||
export function canonicalizeKeyInput(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
const lower = value.toLowerCase();
|
||||
return KEY_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
export function deriveCodeForKey(value) {
|
||||
const canonical = canonicalizeKeyInput(value);
|
||||
if (!canonical) return null;
|
||||
if (BINDING_CODE_MAP[canonical]) return BINDING_CODE_MAP[canonical];
|
||||
if (canonical.length === 1) {
|
||||
if (/[a-z]/.test(canonical)) return `Key${canonical.toUpperCase()}`;
|
||||
if (/[0-9]/.test(canonical)) return `Digit${canonical}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createKeyToken(value) {
|
||||
const canonical = canonicalizeKeyInput(value);
|
||||
return canonical ? `key:${canonical}` : null;
|
||||
}
|
||||
|
||||
export function createCodeToken(value) {
|
||||
const code = deriveCodeForKey(value);
|
||||
return code ? `code:${code}` : null;
|
||||
}
|
||||
|
||||
export function tokensForEvent(event) {
|
||||
const tokens = new Set();
|
||||
const keyToken = createKeyToken(event?.key ?? '');
|
||||
if (keyToken) tokens.add(keyToken);
|
||||
const codeToken = event?.code ? `code:${event.code}` : null;
|
||||
if (codeToken) tokens.add(codeToken);
|
||||
return Array.from(tokens);
|
||||
}
|
||||
|
||||
export function normalizeKeymapEntries(keymap = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(keymap).map(([action, bindings]) => {
|
||||
const values = Array.isArray(bindings) ? bindings : [bindings];
|
||||
const normalized = new Set();
|
||||
values.forEach((value) => {
|
||||
const keyToken = createKeyToken(String(value));
|
||||
if (keyToken) normalized.add(keyToken);
|
||||
const codeToken = createCodeToken(String(value));
|
||||
if (codeToken) normalized.add(codeToken);
|
||||
});
|
||||
return [action, normalized];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatKeyLabel(value) {
|
||||
const canonical = canonicalizeKeyInput(value);
|
||||
if (!canonical) return '—';
|
||||
if (canonical === ' ') return 'Space';
|
||||
if (canonical === '\\') return 'Backslash';
|
||||
if (canonical === '`') return 'Backtick';
|
||||
if (canonical === '[') return '[ or {';
|
||||
if (canonical === ']') return '] or }';
|
||||
if (canonical === ';') return '; or :';
|
||||
if (canonical === "'") return "' or \"";
|
||||
if (canonical === ',') return ', or <';
|
||||
if (canonical === '.') return '. or >';
|
||||
if (canonical === '/') return '/ or ?';
|
||||
if (canonical === '-') return '- or _';
|
||||
if (canonical === '=') return '= or +';
|
||||
return canonical.length === 1 ? canonical.toUpperCase() : canonical;
|
||||
}
|
||||
Reference in New Issue
Block a user