mobile reorganizations

This commit is contained in:
legop3
2026-06-10 15:17:28 -04:00
parent 4fd44d6ad6
commit cb5b58666c
11 changed files with 669 additions and 642 deletions
@@ -0,0 +1,187 @@
// Control Pad Panel
// Purpose: Provides the mobile movement control pad and speed mode selector.
// Scope: Converts touch pad cells into keyboard-style drive vectors; movement column owns drive/dock placement.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useControlSystem } from '../../controls/index.js';
import { normalizeKeymapEntries } from '../../controls/keymapUtils.js';
import {
computeKeyboardDriveVector,
getKeyboardDriveSpeedOptions,
resolveKeyboardSpeeds,
} from '../../controls/inputs/driveIntent.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { INPUT_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import FloatingJoystick from './FloatingJoystick.jsx';
import {
DRIVE_PAD_REPEAT_MS,
DRIVE_PAD_SPEED_MODES,
SOURCE,
} from './constants.js';
function firstTokenForAction(keymap, actionId) {
const bindingSet = keymap?.[actionId];
if (!bindingSet || bindingSet.size === 0) return null;
return bindingSet.values().next().value ?? null;
}
function getSpeedModeConfig(speedMode) {
return DRIVE_PAD_SPEED_MODES.find((mode) => mode.id === speedMode) || DRIVE_PAD_SPEED_MODES[1];
}
export default function ControlPadPanel({ disabled = false }) {
const {
state: { keymap: rawKeymap },
actions: { setDriveVector, registerInputState },
} = useControlSystem();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
const [speedMode, setSpeedMode] = useState('normal');
const [activeInputLabel, setActiveInputLabel] = useState('stop');
const speedModeRef = useRef('normal');
const activeCellRef = useRef(null);
const repeatTimerRef = useRef(null);
const keymap = useMemo(() => normalizeKeymapEntries(rawKeymap), [rawKeymap]);
const keyboardSpeeds = useMemo(() => resolveKeyboardSpeeds(inputSettings), [inputSettings]);
const clearRepeatTimer = useCallback(() => {
if (!repeatTimerRef.current) return;
clearInterval(repeatTimerRef.current);
repeatTimerRef.current = null;
}, []);
const buildVirtualKeyTokens = useCallback(
(cell, modeId = speedModeRef.current) => {
const tokens = new Set();
const speedModeConfig = getSpeedModeConfig(modeId);
const actionIds = [
...(Array.isArray(cell?.actions) ? cell.actions : []),
speedModeConfig.modifierAction,
].filter(Boolean);
actionIds.forEach((actionId) => {
const token = firstTokenForAction(keymap, actionId);
if (token) tokens.add(token);
});
return tokens;
},
[keymap],
);
const sendDriveCell = useCallback(
(cell, lastEvent = 'move', modeId = speedModeRef.current) => {
if (disabled) return;
const tokens = buildVirtualKeyTokens(cell, modeId);
const vector = computeKeyboardDriveVector(tokens, keymap);
const speedOptions = getKeyboardDriveSpeedOptions(tokens, keymap, keyboardSpeeds);
// Mobile deliberately routes through the keyboard vector/speed helpers. The
// thumb pad only chooses which virtual keys are down, so changes to keyboard
// drive behavior automatically stay matched here.
setDriveVector(vector, { source: SOURCE, speedOptions });
registerInputState(SOURCE, {
keys: Array.from(tokens),
vector,
activeCell: cell?.id ?? 'stop',
speedMode: modeId,
lastEvent,
});
},
[
buildVirtualKeyTokens,
disabled,
keyboardSpeeds,
keymap,
registerInputState,
setDriveVector,
],
);
const stopDrivePad = useCallback(
(lastEvent = 'stop') => {
clearRepeatTimer();
activeCellRef.current = null;
setActiveInputLabel('stop');
sendDriveCell({ id: 'stop', actions: [] }, lastEvent, 'normal');
},
[clearRepeatTimer, sendDriveCell],
);
const startRepeatTimer = useCallback(() => {
if (repeatTimerRef.current) return;
repeatTimerRef.current = setInterval(() => {
if (!activeCellRef.current) return;
sendDriveCell(activeCellRef.current, 'repeat');
}, DRIVE_PAD_REPEAT_MS);
}, [sendDriveCell]);
const handleCellChange = useCallback(
(cell) => {
if (disabled) return;
activeCellRef.current = cell;
setActiveInputLabel(cell?.label || 'stop');
sendDriveCell(cell, 'move');
startRepeatTimer();
},
[disabled, sendDriveCell, startRepeatTimer],
);
const handleSpeedModeChange = useCallback(
(nextMode) => {
setSpeedMode(nextMode);
speedModeRef.current = nextMode;
if (activeCellRef.current) {
sendDriveCell(activeCellRef.current, 'speed', nextMode);
}
},
[sendDriveCell],
);
useEffect(() => {
return () => clearRepeatTimer();
}, [clearRepeatTimer]);
useEffect(() => {
if (!disabled) return;
stopDrivePad('disabled');
}, [disabled, stopDrivePad]);
return (
<div className="mobile-touch-control flex flex-1 min-h-0 flex-col overflow-hidden rounded-xl border-2 border-slate-700 bg-slate-900 text-slate-100 shadow-md">
<div className="mobile-touch-control grid grid-cols-3 gap-0.5 border-b border-slate-700 bg-slate-950 p-0.5">
{DRIVE_PAD_SPEED_MODES.map((mode) => {
const active = speedMode === mode.id;
const speedValue =
mode.id === 'precision'
? keyboardSpeeds.precisionSpeed
: mode.id === 'turbo'
? keyboardSpeeds.turboSpeed
: keyboardSpeeds.baseSpeed;
return (
<button
key={mode.id}
type="button"
className={`mobile-touch-control min-h-9 rounded-md px-1 text-xs font-semibold ${
active
? 'bg-cyan-300 text-slate-950'
: 'bg-slate-800 text-slate-200'
}`}
onClick={() => handleSpeedModeChange(mode.id)}
disabled={disabled}
>
<span className="block leading-tight">{mode.label}</span>
<span className="block font-mono text-[0.7rem] leading-tight">{speedValue}</span>
</button>
);
})}
</div>
<div className="mobile-touch-control min-h-0 flex-1">
<FloatingJoystick
activeInputLabel={activeInputLabel}
disabled={disabled}
onCellChange={handleCellChange}
onStop={() => stopDrivePad('stop')}
/>
</div>
</div>
);
}