diff --git a/frontend/src/components/ControlFeedback.vue b/frontend/src/components/ControlFeedback.vue index b45420e..6a2526a 100644 --- a/frontend/src/components/ControlFeedback.vue +++ b/frontend/src/components/ControlFeedback.vue @@ -14,6 +14,8 @@ const { activeSourceLabel, commandLabel, controlLimits, + controlInputMode, + controlInputModeLabel, controlTuning, commandValues, gamepadActive, @@ -24,6 +26,7 @@ const { gamepadMapping, gamepadName, gamepadRightStick, + keyboardActive, keyboardKeys, keyboardTurbo, lastServerMessage, @@ -77,8 +80,13 @@ function stickOffset(value: number) { @@ -127,6 +158,96 @@ h2 { font-size: 24px; } +.mode-panel { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 18px; + background: rgba(7, 14, 26, 0.86); + border: 1px solid rgba(133, 147, 169, 0.18); +} + +.mode-panel-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: start; +} + +.mode-eyebrow { + margin: 0 0 4px; + color: #7bc4ff; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 12px; + font-weight: 700; +} + +.mode-copy { + margin: 0; + color: #d5dbee; + line-height: 1.6; +} + +.mode-current { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 12px; + border-radius: 999px; + background: rgba(123, 196, 255, 0.14); + color: #dff1ff; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mode-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.mode-button { + display: grid; + gap: 6px; + min-height: 84px; + padding: 14px; + border-radius: 16px; + border: 1px solid rgba(133, 147, 169, 0.18); + background: rgba(10, 20, 37, 0.9); + color: #dfe7fb; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.mode-button strong { + font-size: 15px; +} + +.mode-button span { + color: #96a5c3; + font-size: 13px; + line-height: 1.5; +} + +.mode-button:hover { + border-color: rgba(123, 196, 255, 0.4); + transform: translateY(-1px); +} + +.mode-button.active { + border-color: rgba(123, 196, 255, 0.6); + background: linear-gradient(135deg, rgba(91, 122, 255, 0.24), rgba(77, 212, 172, 0.2)); + box-shadow: 0 10px 28px rgba(91, 122, 255, 0.18); +} + +.mode-button.active span { + color: #d5e7ff; +} + .tuning-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -184,12 +305,17 @@ h2 { align-items: start; } + .mode-panel-head { + flex-direction: column; + } + .tuning-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 640px) { + .mode-toggle, .tuning-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/composables/useControlInterface.ts b/frontend/src/composables/useControlInterface.ts index 192e0e0..96a39ff 100644 --- a/frontend/src/composables/useControlInterface.ts +++ b/frontend/src/composables/useControlInterface.ts @@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { buildControlWebSocketUrl } from '@/lib/api' type SocketState = 'connecting' | 'open' | 'closed' +type ControlInputMode = 'keyboard' | 'gamepad' type ControlSource = 'keyboard' | 'gamepad' | 'idle' type CommandTuple = [number, number, number, number, number, number] @@ -47,6 +48,7 @@ const DEFAULT_CONTROL_TUNING: ControlTuning = { turn: 0.4, turbo: 1.5, } +const CONTROL_INPUT_MODE_STORAGE_KEY = 'robot-command-center.control-input-mode' const CONTROL_TUNING_STORAGE_KEY = 'robot-command-center.control-tuning' const MIN_AXIS_SPEED = 0.05 const MAX_AXIS_SPEED = 3 @@ -94,6 +96,10 @@ function normalizeControlTuning(raw?: Partial): ControlTuning { } } +function normalizeControlInputMode(raw: unknown): ControlInputMode { + return raw === 'gamepad' ? 'gamepad' : 'keyboard' +} + function loadPersistedControlTuning() { if (typeof window === 'undefined') { return DEFAULT_CONTROL_TUNING @@ -118,6 +124,19 @@ function loadPersistedControlTuning() { } } +function loadPersistedControlInputMode() { + if (typeof window === 'undefined') { + return normalizeControlInputMode(null) + } + + try { + return normalizeControlInputMode(window.localStorage.getItem(CONTROL_INPUT_MODE_STORAGE_KEY)) + } catch { + return normalizeControlInputMode(null) + } +} + +const controlInputMode = ref(loadPersistedControlInputMode()) const initialControlTuning = loadPersistedControlTuning() const forwardSpeed = ref(initialControlTuning.forward) const strafeSpeed = ref(initialControlTuning.strafe) @@ -165,6 +184,36 @@ function persistControlTuning() { } } +function persistControlInputMode() { + if (typeof window === 'undefined') { + return + } + + try { + window.localStorage.setItem(CONTROL_INPUT_MODE_STORAGE_KEY, controlInputMode.value) + } catch { + // Ignore storage failures so mode switching still works for the current session. + } +} + +function setControlInputMode(next: ControlInputMode) { + const resolved = normalizeControlInputMode(next) + const previous = controlInputMode.value + + if (resolved === previous) { + return + } + + controlInputMode.value = resolved + persistControlInputMode() + + if (previous === 'keyboard') { + pressedKeys.value = new Set() + } + + refreshSendLoop(true) +} + function setControlTuning(next: Partial) { const resolved = normalizeControlTuning({ forward: next.forward ?? forwardSpeed.value, @@ -262,22 +311,30 @@ function gamepadCommandValues(): CommandTuple { return [lx, ly, 0, 0, 0, az] } -function keyboardActive() { +function keyboardActiveRaw() { return pressedKeys.value.size > 0 } -function gamepadActiveInternal() { +function keyboardActive() { + return controlInputMode.value === 'keyboard' && keyboardActiveRaw() +} + +function gamepadActiveRaw() { if (!gamepadConnected.value) { return false } return !isZeroCommand(gamepadCommandValues()) || gamepadButtonPressed.value.some(Boolean) } +function gamepadActiveInternal() { + return controlInputMode.value === 'gamepad' && gamepadActiveRaw() +} + function resolvedSource(): ControlSource { - if (keyboardActive()) { + if (controlInputMode.value === 'keyboard' && keyboardActiveRaw()) { return 'keyboard' } - if (gamepadActiveInternal()) { + if (controlInputMode.value === 'gamepad' && gamepadActiveRaw()) { return 'gamepad' } return 'idle' @@ -343,6 +400,9 @@ function handleKeydown(event: KeyboardEvent) { if (!TRACKED_KEYS.includes(event.code)) { return } + if (controlInputMode.value !== 'keyboard') { + return + } if (event.target instanceof HTMLElement) { const tag = event.target.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { @@ -361,6 +421,9 @@ function handleKeyup(event: KeyboardEvent) { if (!TRACKED_KEYS.includes(event.code)) { return } + if (controlInputMode.value !== 'keyboard') { + return + } event.preventDefault() const next = new Set(pressedKeys.value) @@ -382,7 +445,9 @@ function pollGamepadState() { gamepadSupported.value = typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function' if (!gamepadSupported.value) { resetGamepadState() - refreshSendLoop() + if (controlInputMode.value === 'gamepad') { + refreshSendLoop() + } return } @@ -392,7 +457,9 @@ function pollGamepadState() { if (gamepadConnected.value) { resetGamepadState() lastGamepadSignature = '' - refreshSendLoop() + if (controlInputMode.value === 'gamepad') { + refreshSendLoop() + } } return } @@ -412,7 +479,9 @@ function pollGamepadState() { gamepadMapping.value = pad.mapping || 'unknown' gamepadAxes.value = axes gamepadButtonPressed.value = buttons - refreshSendLoop() + if (controlInputMode.value === 'gamepad') { + refreshSendLoop() + } } function connectSocket() { @@ -528,6 +597,11 @@ const activeSourceLabel = computed(() => { return 'Idle' }) +const controlInputModeLabel = computed(() => { + if (controlInputMode.value === 'gamepad') return 'Gamepad' + return 'Keyboard' +}) + const commandValues = computed(() => { const [lx, ly, lz, ax, ay, az] = resolvedCommandValues() return { lx, ly, lz, ax, ay, az } @@ -561,7 +635,9 @@ const keyboardKeys = computed(() => })), ) -const keyboardTurbo = computed(() => pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')) +const keyboardTurbo = computed( + () => controlInputMode.value === 'keyboard' && (pressedKeys.value.has('ShiftLeft') || pressedKeys.value.has('ShiftRight')), +) const controlTuning = computed(() => ({ forward: forwardSpeed.value, strafe: strafeSpeed.value, @@ -601,6 +677,9 @@ export function useControlInterface() { }) return { + controlInputMode, + controlInputModeLabel, + setControlInputMode, socketState, socketLabel, lastServerMessage,