diff --git a/frontend/src/__tests__/key-button-bindings.test.ts b/frontend/src/__tests__/key-button-bindings.test.ts new file mode 100644 index 00000000..72d2e311 --- /dev/null +++ b/frontend/src/__tests__/key-button-bindings.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + isKeyBindable, + normalizeKey, + formatKeyLabel, + isModifierKey, + buildKeyBindingMap, +} from '../utils/keyButtonBindings'; + +const comp = (id: string, metadataId: string, key?: unknown) => ({ + id, + metadataId, + properties: key === undefined ? {} : { key }, +}); + +describe('keyButtonBindings', () => { + it('marks both pushbutton variants as bindable, other parts not', () => { + expect(isKeyBindable('pushbutton')).toBe(true); + expect(isKeyBindable('pushbutton-6mm')).toBe(true); + expect(isKeyBindable('slide-switch')).toBe(false); + expect(isKeyBindable('led')).toBe(false); + }); + + it('normalizes single characters case-insensitively, keeps named keys', () => { + expect(normalizeKey('A')).toBe('a'); + expect(normalizeKey('a')).toBe('a'); + expect(normalizeKey('ArrowUp')).toBe('ArrowUp'); + expect(normalizeKey(' ')).toBe(' '); + }); + + it('formats key labels for the keycap badge', () => { + expect(formatKeyLabel('a')).toBe('A'); + expect(formatKeyLabel(' ')).toBe('Space'); + expect(formatKeyLabel('ArrowUp')).toBe('Up'); + expect(formatKeyLabel('Enter')).toBe('Enter'); + }); + + it('rejects lone modifiers', () => { + expect(isModifierKey('Shift')).toBe(true); + expect(isModifierKey('Meta')).toBe(true); + expect(isModifierKey('a')).toBe(false); + expect(isModifierKey('Enter')).toBe(false); + }); + + it('builds the key → component-ids map from component properties', () => { + const map = buildKeyBindingMap([ + comp('b1', 'pushbutton', 'a'), + comp('b2', 'pushbutton-6mm', 'A'), // same key, different case + comp('b3', 'pushbutton', 'ArrowUp'), + comp('b4', 'pushbutton'), // unbound + comp('b5', 'pushbutton', ''), // cleared binding + comp('led1', 'led', 'a'), // not bindable — ignored + comp('b6', 'pushbutton', 42), // junk value — ignored + ]); + expect(map.get('a')).toEqual(['b1', 'b2']); + expect(map.get('ArrowUp')).toEqual(['b3']); + expect(map.size).toBe(2); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index ec2f55f6..724ceb3d 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -19,6 +19,7 @@ import { useEditorStore } from '../store/useEditorStore'; import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles'; import { PartSimulationRegistry } from '../simulation/parts'; import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; +import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings'; import { createDefaultPinResolver, createSpiceResolvedPinResolver, @@ -684,6 +685,25 @@ export const DynamicComponent: React.FC = ({ }} > {properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name} + {isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && ( + + {formatKeyLabel(properties.key)} + + )} {properties.protocol && ( = ( // window.confirm() call which was visually jarring on mobile. const [confirmingDelete, setConfirmingDelete] = useState(false); + // Keyboard binding capture (pushbuttons): while true, the next keydown + // becomes the component's `key` property. Escape cancels the capture. + const [capturingKey, setCapturingKey] = useState(false); + const allComponents = useSimulatorStore((s) => s.components); + const boundKey = + typeof componentProperties.key === 'string' && componentProperties.key + ? componentProperties.key + : ''; + const keyInUseElsewhere = + !!boundKey && + allComponents.some( + (c) => + c.id !== componentId && + isKeyBindable(c.metadataId) && + typeof c.properties.key === 'string' && + !!c.properties.key && + normalizeKey(c.properties.key) === normalizeKey(boundKey), + ); + + useEffect(() => setCapturingKey(false), [componentId]); + + useEffect(() => { + if (!capturingKey) return; + const onCaptureKey = (e: KeyboardEvent) => { + // Capture-phase + stopPropagation: the pressed key must not reach the + // dialog's Escape-close handler, the canvas delete handler, or the + // runtime key→button bridge while we're recording it. + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') { + setCapturingKey(false); + return; + } + if (isModifierKey(e.key)) return; // keep waiting for a real key + onPropertyChange?.(componentId, 'key', normalizeKey(e.key)); + setCapturingKey(false); + }; + window.addEventListener('keydown', onCaptureKey, true); + return () => window.removeEventListener('keydown', onCaptureKey, true); + }, [capturingKey, componentId, onPropertyChange]); + // Calculate dialog position on mount — clamp within canvas viewport useEffect(() => { if (!dialogRef.current) return; @@ -248,6 +296,45 @@ export const ComponentPropertyDialog: React.FC = ( )} + {/* Keyboard binding — drive this pushbutton with a keyboard key */} + {isKeyBindable(componentMetadata.id) && ( +
+
+ +
+ + {boundKey && !capturingKey && ( + + )} +
+
+ {keyInUseElsewhere && ( +
{t('editor.componentProps.keyInUse')}
+ )} +
+ )} + {/* microSD card — upload your own files (paid). Free auto-copy of the project's files happens at simulation start, not here. */} {componentMetadata.id === 'microsd-card' && ( diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index de7950a7..4b83fc6f 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -61,6 +61,7 @@ import { import { SelectionActionBar } from './SelectionActionBar'; import { WireModeBanner } from './WireModeBanner'; import { PinPickerDialog } from './PinPickerDialog'; +import { useButtonKeyBindings } from '../../hooks/useButtonKeyBindings'; import './SimulatorCanvas.css'; /** World-units of tolerance for alignment snap (scales with zoom). */ @@ -137,6 +138,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Active board (for WiFi/BLE status display) const activeBoard = boards.find((b) => b.id === activeBoardId) ?? null; + // Keyboard → pushbutton bindings (component `key` property). + useButtonKeyBindings(components); + // Legacy derived values for components that still use them const boardType = useSimulatorStore((s) => s.boardType); const boardPosition = useSimulatorStore((s) => s.boardPosition); diff --git a/frontend/src/hooks/useButtonKeyBindings.ts b/frontend/src/hooks/useButtonKeyBindings.ts new file mode 100644 index 00000000..e3046710 --- /dev/null +++ b/frontend/src/hooks/useButtonKeyBindings.ts @@ -0,0 +1,93 @@ +/** + * Global keyboard bridge for pushbuttons mapped to keys. + * + * keydown presses every button bound to that key, keyup releases it. The + * bridge dispatches synthetic `button-press` / `button-release` events on + * the component's wokwi element — the exact events its mouse interaction + * fires — so every consumer (BasicParts pin logic, SPICE-driven inputs, + * the QEMU GPIO bridge, the pressed visual) behaves identically to a + * mouse click with zero per-simulator wiring. + * + * Guards: + * - ignored while typing in inputs / textareas / selects / Monaco + * (contentEditable) so mapped letters don't fire while editing code + * - ignored with Ctrl/Alt/Meta held (browser & app shortcuts win) + * - auto-repeat keydowns are ignored (a held key = one long press) + * - window blur releases everything (no stuck buttons after Alt-Tab) + */ + +import { useEffect, useRef } from 'react'; +import { buildKeyBindingMap, normalizeKey } from '../utils/keyButtonBindings'; + +interface BoundComponent { + id: string; + metadataId: string; + properties: Record; +} + +function componentElement(componentId: string): Element | null { + return ( + document.querySelector( + `[data-component-id="${componentId}"] .web-component-container > *`, + ) ?? null + ); +} + +function isTypingTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + if (!el || !el.tagName) return false; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; +} + +export function useButtonKeyBindings(components: BoundComponent[]): void { + // Rebuilt on every render pass where components changed; the listeners + // read through the ref so they never go stale without re-binding. + const bindingsRef = useRef>(new Map()); + bindingsRef.current = buildKeyBindingMap(components); + + // key → component ids currently held down via that key. + const downRef = useRef>(new Map()); + + useEffect(() => { + const dispatch = (componentId: string, type: 'button-press' | 'button-release') => { + componentElement(componentId)?.dispatchEvent(new Event(type)); + }; + + const releaseKey = (norm: string) => { + const held = downRef.current.get(norm); + if (!held) return; + downRef.current.delete(norm); + for (const id of held) dispatch(id, 'button-release'); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.repeat || e.ctrlKey || e.altKey || e.metaKey) return; + if (isTypingTarget(e.target)) return; + const norm = normalizeKey(e.key); + const ids = bindingsRef.current.get(norm); + if (!ids || ids.length === 0 || downRef.current.has(norm)) return; + e.preventDefault(); + downRef.current.set(norm, [...ids]); + for (const id of ids) dispatch(id, 'button-press'); + }; + + const onKeyUp = (e: KeyboardEvent) => { + releaseKey(normalizeKey(e.key)); + }; + + const onBlur = () => { + for (const norm of Array.from(downRef.current.keys())) releaseKey(norm); + }; + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + window.removeEventListener('blur', onBlur); + onBlur(); + }; + }, []); +} diff --git a/frontend/src/i18n/locales/de/common2.json b/frontend/src/i18n/locales/de/common2.json index dd7f5fbc..1a661fe5 100644 --- a/frontend/src/i18n/locales/de/common2.json +++ b/frontend/src/i18n/locales/de/common2.json @@ -239,7 +239,12 @@ "rotate90": "90° drehen", "rotate": "Drehen", "delete": "Löschen", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "keyboardKey": "Tastaturtaste", + "assignKey": "Taste zuweisen", + "pressAKey": "Taste drücken…", + "clearKey": "Tastenzuweisung entfernen", + "keyInUse": "Auch einem anderen Button zugewiesen — beide werden zusammen gedrückt." }, "selectionBar": { "label": "Auswahlaktionen", diff --git a/frontend/src/i18n/locales/en/common2.json b/frontend/src/i18n/locales/en/common2.json index 422ee8e7..6f41b532 100644 --- a/frontend/src/i18n/locales/en/common2.json +++ b/frontend/src/i18n/locales/en/common2.json @@ -241,7 +241,12 @@ "rotate90": "Rotate 90°", "rotate": "Rotate", "delete": "Delete", - "cancel": "Cancel" + "cancel": "Cancel", + "keyboardKey": "Keyboard key", + "assignKey": "Assign key", + "pressAKey": "Press a key…", + "clearKey": "Remove key binding", + "keyInUse": "Also assigned to another button — both will press together." }, "selectionBar": { "label": "Selection actions", diff --git a/frontend/src/i18n/locales/es/common2.json b/frontend/src/i18n/locales/es/common2.json index f845cf99..51240fc2 100644 --- a/frontend/src/i18n/locales/es/common2.json +++ b/frontend/src/i18n/locales/es/common2.json @@ -241,7 +241,12 @@ "rotate90": "Rotar 90°", "rotate": "Rotar", "delete": "Eliminar", - "cancel": "Cancelar" + "cancel": "Cancelar", + "keyboardKey": "Tecla del teclado", + "assignKey": "Asignar tecla", + "pressAKey": "Pulsa una tecla…", + "clearKey": "Quitar tecla", + "keyInUse": "También asignada a otro botón: se pulsarán a la vez." }, "selectionBar": { "label": "Acciones de selección", diff --git a/frontend/src/i18n/locales/fr/common2.json b/frontend/src/i18n/locales/fr/common2.json index c51d2783..131d1ee3 100644 --- a/frontend/src/i18n/locales/fr/common2.json +++ b/frontend/src/i18n/locales/fr/common2.json @@ -239,7 +239,12 @@ "rotate90": "Rotation 90°", "rotate": "Pivoter", "delete": "Supprimer", - "cancel": "Annuler" + "cancel": "Annuler", + "keyboardKey": "Touche du clavier", + "assignKey": "Assigner une touche", + "pressAKey": "Appuyez sur une touche…", + "clearKey": "Supprimer le raccourci", + "keyInUse": "Aussi assignée à un autre bouton — les deux seront pressés ensemble." }, "selectionBar": { "label": "Actions de sélection", diff --git a/frontend/src/i18n/locales/it/common2.json b/frontend/src/i18n/locales/it/common2.json index d9973214..9f4566ab 100644 --- a/frontend/src/i18n/locales/it/common2.json +++ b/frontend/src/i18n/locales/it/common2.json @@ -239,7 +239,12 @@ "rotate90": "Ruota 90°", "rotate": "Ruota", "delete": "Elimina", - "cancel": "Annulla" + "cancel": "Annulla", + "keyboardKey": "Tasto della tastiera", + "assignKey": "Assegna tasto", + "pressAKey": "Premi un tasto…", + "clearKey": "Rimuovi il tasto", + "keyInUse": "Assegnato anche a un altro pulsante: verranno premuti insieme." }, "selectionBar": { "label": "Azioni di selezione", diff --git a/frontend/src/i18n/locales/ja/common2.json b/frontend/src/i18n/locales/ja/common2.json index 26f48980..c2b143be 100644 --- a/frontend/src/i18n/locales/ja/common2.json +++ b/frontend/src/i18n/locales/ja/common2.json @@ -239,7 +239,12 @@ "rotate90": "90°回転", "rotate": "回転", "delete": "削除", - "cancel": "キャンセル" + "cancel": "キャンセル", + "keyboardKey": "キーボードキー", + "assignKey": "キーを割り当て", + "pressAKey": "キーを押してください…", + "clearKey": "キー割り当てを解除", + "keyInUse": "他のボタンにも割り当てられています。同時に押されます。" }, "selectionBar": { "label": "選択アクション", diff --git a/frontend/src/i18n/locales/pt-br/common2.json b/frontend/src/i18n/locales/pt-br/common2.json index 93ea34f3..a72ced56 100644 --- a/frontend/src/i18n/locales/pt-br/common2.json +++ b/frontend/src/i18n/locales/pt-br/common2.json @@ -239,7 +239,12 @@ "rotate90": "Girar 90°", "rotate": "Girar", "delete": "Excluir", - "cancel": "Cancelar" + "cancel": "Cancelar", + "keyboardKey": "Tecla do teclado", + "assignKey": "Atribuir tecla", + "pressAKey": "Pressione uma tecla…", + "clearKey": "Remover tecla", + "keyInUse": "Também atribuída a outro botão — os dois serão pressionados juntos." }, "selectionBar": { "label": "Ações de seleção", diff --git a/frontend/src/i18n/locales/ru/common2.json b/frontend/src/i18n/locales/ru/common2.json index 357c4544..98c4601b 100644 --- a/frontend/src/i18n/locales/ru/common2.json +++ b/frontend/src/i18n/locales/ru/common2.json @@ -239,7 +239,12 @@ "rotate90": "Повернуть на 90°", "rotate": "Повернуть", "delete": "Удалить", - "cancel": "Отмена" + "cancel": "Отмена", + "keyboardKey": "Клавиша клавиатуры", + "assignKey": "Назначить клавишу", + "pressAKey": "Нажмите клавишу…", + "clearKey": "Убрать привязку клавиши", + "keyInUse": "Также назначена другой кнопке — обе будут нажаты одновременно." }, "selectionBar": { "label": "Действия с выделением", diff --git a/frontend/src/i18n/locales/zh-cn/common2.json b/frontend/src/i18n/locales/zh-cn/common2.json index ec0d4894..9757522f 100644 --- a/frontend/src/i18n/locales/zh-cn/common2.json +++ b/frontend/src/i18n/locales/zh-cn/common2.json @@ -239,7 +239,12 @@ "rotate90": "旋转 90°", "rotate": "旋转", "delete": "删除", - "cancel": "取消" + "cancel": "取消", + "keyboardKey": "键盘按键", + "assignKey": "分配按键", + "pressAKey": "请按一个键…", + "clearKey": "移除按键绑定", + "keyInUse": "也已分配给另一个按钮,两个按钮会同时按下。" }, "selectionBar": { "label": "选择操作", diff --git a/frontend/src/utils/keyButtonBindings.ts b/frontend/src/utils/keyButtonBindings.ts new file mode 100644 index 00000000..0846102b --- /dev/null +++ b/frontend/src/utils/keyButtonBindings.ts @@ -0,0 +1,79 @@ +/** + * Keyboard-to-button bindings. + * + * Any pushbutton on the canvas can be mapped to a keyboard key via the + * component property dialog. The mapping is stored as the component's + * `key` property (so it persists inside components_json / .vlx exports) + * and is consumed at runtime by `useButtonKeyBindings`, which translates + * keydown/keyup into the same `button-press` / `button-release` DOM + * events the mouse fires on the wokwi element — every simulation path + * (avr8js, SPICE-driven inputs, the QEMU GPIO bridge) already listens to + * those events, so keyboard presses behave exactly like mouse presses. + */ + +/** Structural subset of the canvas component shape this module needs. */ +interface BindableComponent { + id: string; + metadataId: string; + properties: Record; +} + +/** Component metadata ids that support a keyboard binding. */ +const KEY_BINDABLE = new Set(['pushbutton', 'pushbutton-6mm']); + +export function isKeyBindable(metadataId: string): boolean { + return KEY_BINDABLE.has(metadataId); +} + +/** + * Canonical form of a KeyboardEvent.key for storing and matching. + * Single characters are case-folded so a mapping made with CapsLock on + * (or matched while Shift is held) still works; named keys ("ArrowUp", + * "Enter", " ") are kept verbatim. + */ +export function normalizeKey(key: string): string { + return key.length === 1 ? key.toLowerCase() : key; +} + +/** Compact human label for a stored key (keycap badge / dialog chip). */ +export function formatKeyLabel(key: string): string { + switch (key) { + case ' ': + return 'Space'; + case 'ArrowUp': + return 'Up'; + case 'ArrowDown': + return 'Down'; + case 'ArrowLeft': + return 'Left'; + case 'ArrowRight': + return 'Right'; + default: + return key.length === 1 ? key.toUpperCase() : key; + } +} + +/** Modifier and lock keys can't be bound on their own. */ +export function isModifierKey(key: string): boolean { + return ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'NumLock', 'ScrollLock'].includes(key); +} + +/** + * normalized key → component ids bound to it. Several buttons may share + * one key on purpose (e.g. one physical key closing two parallel + * branches), so the value is a list. + */ +export function buildKeyBindingMap( + components: BindableComponent[], +): Map { + const map = new Map(); + for (const c of components) { + const key = c.properties?.key; + if (!isKeyBindable(c.metadataId) || typeof key !== 'string' || !key) continue; + const norm = normalizeKey(key); + const list = map.get(norm); + if (list) list.push(c.id); + else map.set(norm, [c.id]); + } + return map; +}