Skip to content

Editor keyboard input — focus scope + keymap registry

Every keyboard shortcut in the editor is declared in one registry and dispatched by one window listener, resolved against the focused panel. This doc is the contract: what a scope means, what claiming vs. yielding a chord does to the Electron menu, and how editor focus gates the running game's input.

Mouse is deliberately not focus-filtered — DOM hit-testing already routes it. Mousedown only sets focus.

Key files

  • editor/input/keymap.tsregister/unregister/resolve, chord normalization (mod → ⌘ or Ctrl by platform), formatChord (the ⌘D / F2 / glyphs the context menus show), KeymapConflictError.
  • editor/input/dispatcher.tsinstallKeymapDispatcher(), the single window keydown listener.
  • editor/input/focusScope.tsisTextEditable() + the overlay stack (pushOverlay/popOverlay/ topOverlay).
  • editor/input/PanelFocusHost.tsx — click-to-focus wrapper applied by EditorApp's FlexLayout factory, so every panel gets focus acquisition at one seam.
  • editor/input/useOverlayEscape.tsuseOverlayEscape() (push + bind Escape) and useOverlay() (push only, for overlays that own other chords).
  • editor/store/editorStore.tsfocusedPanel + setFocusedPanel.
  • runtime/input/inputSources.tssetInputGate / isInputSuppressed (see input.md); the editor's policy is installed in EditorApp.tsx.

The preventDefault contract — the load-bearing rule

Measured, not assumed (physical keypresses against the dev editor; synthesized input cannot answer this question — see Gotchas).

The renderer sees a key BEFORE the Electron menu, and preventDefault() is what suppresses the menu accelerator / native role.

So the dispatcher's whole body is a two-line contract:

  • Claimed (resolve() returned a binding) → preventDefault() and run it.
  • Yielded (resolve() returned null) → do nothing. No preventDefault.

Yielding is what lets ⌘C in a text field reach the native role:'copy', ⌘R reach reload, and ⌘⌥I reach devtools. A dispatcher that called preventDefault unconditionally would kill every native role editor-wide, silently, with no error anywhere.

Two corollaries that are easy to get backwards:

  • when() returning false means YIELD, not swallow. It removes the binding from the candidate set, so a lower scope — or the menu — gets the chord. Never use when to express "claim it but don't preventDefault".
  • That's what Binding.preventDefault() is for. Claiming and preventing are separate decisions: a binding may legitimately deny a chord to every lower scope while still letting the browser's default run (the SpriteEditor modal claims ⌘Z so a global undo can't unmount it mid-edit, but must not block typing). Getting this wrong produced a real bug — an when: notTyping guard yielded ⌘Z to app.undo, which then ran the scene undo underneath an open modal.

The same measurement showed there is no double-dispatch: a chord bound both as a menu accelerator and as a renderer binding fires once, because the renderer's preventDefault suppresses the menu path. The menu keeps its shortcut: labels purely as display.

Worked example — ⌘⇧N, measured 2026-07-22. It is bound twice: the Electron menu's "New Project…" (engine/electron/projects.ts:225) and Assets' New Folder (editor/panels/Assets.tsx:1433). With the Assets panel focused, a physical press creates a folder and the New Project dialog does not open — single dispatch, Assets wins. Two things this pins that the contract alone did not:

  • It holds for a raw DOM handler, not just registry bindings — Assets' handler is element-scoped and outside the keymap (TODO(P8) at Assets.tsx:1427), yet its preventDefault() still suppresses the native accelerator.
  • The outcome had been asserted twice from theory ("swallowed by New Project", then "double-fires") and both were wrong. It is also not answerable by tooling: synthesized input never reaches a native accelerator, and macOS focus-stealing prevention blocks an agent from raising the editor to send a real one. A human at the keyboard is the only instrument.

Scope tiers

Five, resolved by priority. resolve() picks the highest-priority candidate for the chord.

TierFiresMembers
overlayonly for the top of the overlay stack; outranks everything, so it may swallow an app-chordEscape-to-close, the SpriteEditor modal's ⌘Z
text-fieldwhen document.activeElement is text-editableEnter/Escape commits, Backspace-clears-ref
<panelId>only when that panel is focused, and never while text-editableeverything panel-specific
app-keyeverywhere except text-editablef (frame selected)
app-chordeverywhere, text fields included⌘S, ⌘Z, ⌘⇧Z, ⌘P

The two-way split of "app" is the whole point: f must frame the selection from any panel, but must not fire while you type "fog" into a name field. A panel scope is a FlexLayout tab component idscene, game, hierarchy, inspector, console, assets, particle-editor, animation-editor, timeline-editor, spriteanim-editor, skin-editor, ai, plus any game-registered id.

A command that logically belongs to more than one panel registers once per scope (Hierarchy's selection commands are registered under both hierarchy and scene, so copy/paste works with the viewport focused). Rename stays Hierarchy-only — the edit box lives on a row.

Focus is store-backed, not document.activeElement

focusedPanel lives in editorStore and is set on capture-phase pointerdown by PanelFocusHost. This is load-bearing: clicking a Hierarchy row (a plain <div>) does not move DOM focus — every measured keypress after such a click reports target=BODY. A derived-from-activeElement model has to special-case "still run when activeElement is <body>", which is exactly the hole the old data-editor-panel half-mechanism had.

Focus is also kept out of the FlexLayout model: onModelChange debounce-saves the layout and re-pushes the native menu over IPC, so model-resident focus would rewrite the layout autosave on every click.

Other properties worth knowing:

  • Not undoable, ever. Focus is transient chrome; storing it would flood the undo stack and create a routing feedback loop (undo changes focus → the next ⌘Z routes elsewhere). Focus may follow undo (reveal the owning panel), but is always derived, never pushActioned.
  • Not persisted across launches.
  • Observable as datafocusedPanel is in modoki_get_editor_state, and a scope change journals !focus {panel, from, to, source} (on change only, never per keystroke). A focus ring that existed only as CSS would make "which panel owns keys?" a screenshot question, which debug-tools-mcp.md forbids.
  • Drivablemodoki_press_key and modoki_focus take a panel argument. The route fails loudly (400, naming the valid ids) when that panel isn't open, because a panel-scoped chord aimed at a closed panel is otherwise a silent no-op: the dispatcher just yields. panel and selector stay separate on modoki_focus — keyboard scope and activeElement are different questions.

The runtime input gate — mechanism vs. policy

While an editor panel other than the GameView owns the keyboard, the running game must receive nothing. Otherwise typing WASD in the Hierarchy latches the character's movement keys, and a gamepad drives the game while you edit the Inspector.

The split mirrors the injectable clock: the runtime supplies the mechanism, the editor supplies the policy.

  • runtime/input/inputSources.ts exposes setInputGate(fn) / isInputSuppressed(). It lives on the source registry, not in keyboardSource, because all three sources leak and only one had any guard: keyboard (window keydown, editing() only), pointer (no guard), gamepad (polled, no guard).
  • EditorApp installs () => focusedPanel !== null && focusedPanel !== 'game'. A shipped game never calls setInputGate, so the default gate stays null → zero behaviour change in a build, and createTestWorld/headless is untouched.
  • Closing the gate resets held state, on the closing edge. Hold W, click the Hierarchy, and the character must stop — otherwise sample() keeps reporting w held until physical release.
  • Null focus deliberately does not suppress: pressing Play and immediately using WASD has to work without first clicking the GameView.
  • The gate fails open if the policy function throws.

Guards

Nothing structurally prevents the next ad-hoc window.addEventListener('keydown', …) — adding one is the ergonomic thing to do when you want a panel shortcut, it is exactly how the original ten accumulated, and it fails silently (the editor works, verify stays green, the key just fires from the wrong panel). Two source-text tripwires stand in for that:

  • engine/tests/editor/keymapOwnership.test.ts — no raw keyboard listener in editor/** outside an allowlist that must justify each entry. There are exactly two allowed: the dispatcher, and SceneView's Shift-snap, which tracks a modifier level (it needs keyup as much as keydown) rather than dispatching a discrete chord.
  • The same file guards that every scope: literal names a real tier or panel id. Scope has an open (string & {}) arm so a game can own chords, which means scope: 'skin_editor' type-checks, registers, and then never resolves — a silently dead shortcut tsc cannot catch.

Gotchas

  • Synthesized input cannot answer "does the native menu swallow this?" sendInputEvent — and therefore modoki_press_key and CDP Input.dispatchKeyEvent — reaches the renderer but does not trigger native Electron menu accelerators. Verified with a positive control (a bare e set gizmoMode, while ⌘R did not reload). So agent input and human input are on different paths: an agent can "verify" a chord that is dead for a human. That question needs a physical keypress.
  • A green jsdom test is not evidence that input works. fireEvent.mouseDown synthesizes straight into React and cannot reproduce the real pipeline. PanelFocusHost shipped 7/7 green against an implementation that did nothing on the panels that matter most: canvas pointer handlers preventDefault() on pointerdown, which suppresses the compatibility mouse events, so onMouseDownCapture never fired. Fixed with onPointerDownCapture — but the lesson is that an input change must also be verified live (modoki_tap + get_editor_state/journal).
  • The dispatcher is bubble phase, deliberately. A shell-level capture router would preempt five deliberate capture-phase claims and fire before RenameInput's bubble-phase blanket stopPropagation, silently re-breaking inline rename typing.
  • Match the full chord, never key alone. SceneView's modifier bail is what keeps bare r from eating ⌘R — a bug that was already fixed once.
  • Space arrives as ' '. Naive '+'-splitting erases it into the empty chord, which matches nothing with no error. normalizeKeyName() names it first.
  • isTextEditable is narrower than "any form control" — a focused checkbox must not suppress ⌘Z, and a readOnly input is not editable. It must stay in sync with keyboardSource's editing() and its duplicate in rendererOps.ts, or Enact's "focused editable will swallow this" warning lies.
  • The overlay stack pops by id, not top-of-stack, and each hook instance gets its own owner via useId() — two instances of the same overlay kind would otherwise collide into a KeymapConflictError.
  • display: contents on PanelFocusHost — it must add focus acquisition without adding a box, or every panel gains a layout shift.
  • HMR is untrustworthy for this code. Hook order and listener registration change here, so a session that has absorbed a lot of HMR is not evidence either way — a correct fix measured four separate times as "not working" before a forced CDP Page.reload showed it working. Relaunch, or reload, before concluding anything. (Also tracked in todo.md.)
  • editor.md — the editor shell, panels, and undo/redo.
  • input.md — the runtime input seam the gate plugs into.
  • enact.md — trusted input, selector aiming, and the fidelity question above.
  • debug-tools-mcp.mdget_editor_state, editor_journal, and the observe-don't-infer rule.

Built with Modoki.