This commit is contained in:
legop3
2025-11-17 23:48:58 -05:00
parent d0ea99bc4c
commit ffa4189763
10 changed files with 280 additions and 97 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-DXvSpxjQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaCUS21F.css">
<script type="module" crossorigin src="/assets/index-DNSnH2id.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4V7xiyW.css">
</head>
<body>
<div id="root"></div>
@@ -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">
+132
View File
@@ -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>
);
}
+32
View File
@@ -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));
+96
View File
@@ -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;
}