feat(simulator): map pushbuttons to keyboard keys

Any pushbutton (pushbutton / pushbutton-6mm) can now be driven from the
keyboard. Assign a key from the component property dialog — a keycap
control captures the next keypress (Escape cancels, modifiers alone are
rejected) — and a keycap badge next to the component label shows the
mapping on the canvas. Several buttons may share one key on purpose;
the dialog shows a hint when that happens.

At runtime a global bridge translates keydown/keyup into the same
button-press / button-release DOM events the mouse fires on the wokwi
element, so every simulation path (avr8js pin logic, SPICE-driven
inputs, the QEMU GPIO bridge, the pressed visual) behaves identically
to a mouse click. Guards: ignored while typing in inputs or the code
editor, ignored with Ctrl/Alt/Meta held, auto-repeat collapses into one
long press, and window blur releases everything so no button sticks
after Alt-Tab.

The binding is stored as the component's 'key' property, so it
round-trips through project saves and .vlx exports and is undoable like
any other property edit. Strings added to all 9 locales.
This commit is contained in:
David Montero 2026-07-18 02:28:02 +02:00 committed by David Montero Crespo
parent e72413a13f
commit 5218f7314b
16 changed files with 465 additions and 9 deletions

View File

@ -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);
});
});

View File

@ -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<DynamicComponentProps> = ({
}}
>
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
{isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && (
<span
style={{
fontSize: '9px',
padding: '1px 5px',
borderRadius: '3px',
backgroundColor: '#2d2d2d',
color: '#ddd',
border: '1px solid #555',
borderBottomWidth: '2px',
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
fontWeight: 600,
lineHeight: '1.3',
whiteSpace: 'nowrap',
}}
>
{formatKeyLabel(properties.key)}
</span>
)}
{properties.protocol && (
<span
style={{

View File

@ -418,3 +418,72 @@
color: #aaa;
font-variant-numeric: tabular-nums;
}
/* ── Keyboard binding (pushbuttons) ────────────────────────────────── */
.keybind-controls {
display: flex;
align-items: center;
gap: 6px;
}
/* Keycap-styled chip: shows the bound key, or the capture prompt. */
.keybind-chip {
min-width: 44px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
color: #fff;
background-color: #3d3d3d;
border: 1px solid #555;
border-bottom-width: 2px;
border-radius: 4px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
}
.keybind-chip:hover {
border-color: var(--color-action-primary);
}
.keybind-chip--empty {
font-family: inherit;
font-weight: 500;
color: #aaa;
border-style: dashed;
border-bottom-width: 1px;
}
.keybind-chip--capturing {
font-family: inherit;
font-weight: 500;
color: var(--color-action-primary);
border-color: var(--color-action-primary);
box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.25);
}
.keybind-clear {
width: 20px;
height: 20px;
padding: 0;
font-size: 14px;
line-height: 1;
color: #aaa;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
}
.keybind-clear:hover {
color: #fff;
background-color: #3d3d3d;
}
.keybind-hint {
margin-top: 2px;
font-size: 11px;
color: #d19a66;
}

View File

@ -10,6 +10,13 @@ import { useTranslation } from 'react-i18next';
import type { ComponentMetadata } from '../../types/component-metadata';
import { SdCardPanel } from './SdCardPanel';
import type { UploadedSdFile } from '../../utils/sdCardFiles';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import {
isKeyBindable,
isModifierKey,
normalizeKey,
formatKeyLabel,
} from '../../utils/keyButtonBindings';
import './ComponentPropertyDialog.css';
function isEditable(prop: any): boolean {
@ -64,6 +71,47 @@ export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = (
// 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<ComponentPropertyDialogProps> = (
</div>
)}
{/* Keyboard binding — drive this pushbutton with a keyboard key */}
{isKeyBindable(componentMetadata.id) && (
<div className="property-edit-section">
<div className="property-edit-row">
<label className="property-edit-label">
{t('editor.componentProps.keyboardKey')}
</label>
<div className="keybind-controls">
<button
type="button"
className={`keybind-chip${capturingKey ? ' keybind-chip--capturing' : ''}${
!boundKey && !capturingKey ? ' keybind-chip--empty' : ''
}`}
onClick={() => setCapturingKey((c) => !c)}
>
{capturingKey
? t('editor.componentProps.pressAKey')
: boundKey
? formatKeyLabel(boundKey)
: t('editor.componentProps.assignKey')}
</button>
{boundKey && !capturingKey && (
<button
type="button"
className="keybind-clear"
title={t('editor.componentProps.clearKey')}
onClick={() => onPropertyChange?.(componentId, 'key', '')}
>
×
</button>
)}
</div>
</div>
{keyInUseElsewhere && (
<div className="keybind-hint">{t('editor.componentProps.keyInUse')}</div>
)}
</div>
)}
{/* 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' && (

View File

@ -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);

View File

@ -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<string, unknown>;
}
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<Map<string, string[]>>(new Map());
bindingsRef.current = buildKeyBindingMap(components);
// key → component ids currently held down via that key.
const downRef = useRef<Map<string, string[]>>(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();
};
}, []);
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -239,7 +239,12 @@
"rotate90": "90°回転",
"rotate": "回転",
"delete": "削除",
"cancel": "キャンセル"
"cancel": "キャンセル",
"keyboardKey": "キーボードキー",
"assignKey": "キーを割り当て",
"pressAKey": "キーを押してください…",
"clearKey": "キー割り当てを解除",
"keyInUse": "他のボタンにも割り当てられています。同時に押されます。"
},
"selectionBar": {
"label": "選択アクション",

View File

@ -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",

View File

@ -239,7 +239,12 @@
"rotate90": "Повернуть на 90°",
"rotate": "Повернуть",
"delete": "Удалить",
"cancel": "Отмена"
"cancel": "Отмена",
"keyboardKey": "Клавиша клавиатуры",
"assignKey": "Назначить клавишу",
"pressAKey": "Нажмите клавишу…",
"clearKey": "Убрать привязку клавиши",
"keyInUse": "Также назначена другой кнопке — обе будут нажаты одновременно."
},
"selectionBar": {
"label": "Действия с выделением",

View File

@ -239,7 +239,12 @@
"rotate90": "旋转 90°",
"rotate": "旋转",
"delete": "删除",
"cancel": "取消"
"cancel": "取消",
"keyboardKey": "键盘按键",
"assignKey": "分配按键",
"pressAKey": "请按一个键…",
"clearKey": "移除按键绑定",
"keyInUse": "也已分配给另一个按钮,两个按钮会同时按下。"
},
"selectionBar": {
"label": "选择操作",

View File

@ -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<string, unknown>;
}
/** 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<string, string[]> {
const map = new Map<string, string[]>();
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;
}