You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Keybinds 1/4: audit the hardcoded keys in onKeyDown and decide each key's scope #37
Part of #10. First: an audit and a written decision, no behavior change.
onKeyDown hardcodes Space, Delete, Backspace, Escape, G, F and K on a document-level listener. Core already owns some of these (Space = play/pause on the player, Escape = back to library, per the host's built-in shortcuts), so each key needs a deliberate scope.
Scope
List every key handled in onKeyDown with: its action, any guard conditions (typing target, modal open, playback state), and the host built-in it may collide with.
Decide, per key, the window.registerShortcut scope: plugin-editor, player, or global, and the condition guard that preserves today's behavior.
Record the decision table in the issue or PR.
Acceptance criteria
Every key has an action, scope, condition and collision note.
Keys that collide with host built-ins (Space, Escape) have an explicit resolution.
@pullfrog please work on this issue. It is the first sub-issue of #10: an audit and a written decision table, no behavior change. Post the table as a comment here. Do not start the dependent sub-issues.
@itsjustroxi this sub-issue is a decision table for #10 (editor keybinds), and the other keybind sub-issues depend on it. For each hardcoded key (Space, Delete, Backspace, Escape, G, F, K), decide the window.registerShortcut scope (plugin-editor, player or global) and its guard condition. Two need a particular call because the host already owns them: Space (play/pause on the player) and Escape (return to the library), so decide how the editor's own use coexists with the built-ins.
Audit: every key handled in onKeyDown — action, guard, scope decision
No behavior change in this issue. This is the decision record #38, #39 and #40 will implement.
Location:onKeyDown in src/input.js:1610-2016, registered as a document keydown listener at src/main.js:2308. It is not a flat list of bindings — it is an ordered ladder of ~20 mutually-exclusive gates whose order is load-bearing (see the "ladder order" column). Every row below is one key handled by a literal e.key comparison in that file; keys routed through the shortcut-profile tables (_editorDispatchFeedbackShortcut / _editorDispatchEofShortcut, src/input.js:1891-1892) are already data-driven and are not in scope here.
Scope vocabulary (assumption — see Open questions)
plugin-editor — live only while #plugin-editor carries .active. Correct for anything that closes or edits editor chrome (modals, panels, mode-specific verbs). Never claims the key outside the editor screen.
player — belongs to the shared transport/timeline surface: playback and the verbs a DAW player owns (delete-selection, select-all). Used where the verb should stay live across the surfaces the host's player owns.
global — survives screen changes. Recommendation: nothing from this file.
window.editorTogglePlay() → startPlayback() / stopPlayback(), or editorStopRecordMidi() when a take is active (main.js:1617-1631)
Only !e.target.matches('input, select, textarea'). Sits before the recording guard (:1760), the parts-view read-only gate (:1769) and the profile dispatch — so it is live in drum-edit, tempo-map, parts view and mid-take
player — transport, and the host already binds Space as player play/pause
Closes tab-preview lens / user-guide lens / context+section menu / tool palette / cancels a pending tap-tempo run / dismisses suggested-fit ghosts / clears a barline multi-selection / clears the note+drum selection
Ordered and mutually-exclusive-by-construction. main.js:939-968 separately closes add-note, load and command-palette modals and calls stopImmediatePropagation() when one of them consumed the key; tempo-zones.js:278 and anchor-resolve.js:395 own Escape on their own document listeners
plugin-editor, as one registration holding the ladder in today's order — never global
_drumEditorToggleArticulation('g') — toggles ghost-note on selected drum hits
S.drumEditMode && S.drumSel.size && S.drumTab && !shiftKey && !ctrlKey && !metaKey && !altKey && !typing; falls through if no refs. Unreachable while recording (:1760)
plugin-editor
In-plugin only: plain G is toggleSnap (FeedBack) and toggleTempoMap (Logical, input.js:1705-1709). Mutually exclusive today via the S.drumEditMode gate — a real win, the two conditions can never both be true
F
input.js:1790-1810
_drumEditorToggleArticulation('f') — flam
Same guard block as G
plugin-editor
In-plugin only: plain F is editFret in the FeedBack and EOF profiles. Same mutual exclusion via S.drumEditMode
K
input.js:1790-1810
_drumEditorToggleArticulation('k') — choke, filtered to cymbal pieces only (drum.js:1180-1187, so it can no-op with a selection present)
Same guard block as G
plugin-editor
In-plugin only: plain K is cyclePickDirection (FeedBack/EOF) and toggleMetronome in the Logical profile (shortcuts.js:413). Same mutual exclusion, but this is the one row where registering both the drum verb and the profile verb as shortcuts would create a genuine ordering dependency — keep the drum K as plugin-editor and leave cyclePickDirection/toggleMetronome owned by the profile registry for now
Ladder order for Escape (all in onKeyDown, this order is the behavior):
:1621 tab-preview lens — Escape closes, every other key swallowed
:1639 user-guide lens — identical policy
:1660 context/section menu — Escape only, so a dismiss can't also wipe the selection it was opened on
Why Delete and Escape are one registration each, not one per rung
Registering 7 Delete handlers with 7 conditions reproduces today's ladder only if the host evaluates them in registration order and stops at the first match — neither is guaranteed by the API as described in core's docs. The same is true for the 8 Escape owners, and there the overlap is real: with a suggestion ghost up and barlines selected, both :1849 and :1860 conditions are true. Recommendation: keep each ladder as one registerShortcut call whose handler is today's ordered code, and expose one help-panel row per physical key ("Delete / Backspace — delete the current selection", "Escape — close the top layer, or clear the selection"). Seven rows for one key would be discoverable but wrong. The mode-specific variants already get documented in the plugin's own panel, which today renders the registry rows via _editorShortcutRowsPure (shortcuts.js:729-740) — see the framing note below.
Collision resolutions (the two acceptance criteria)
Space vs the host's player play/pause. Same verb, same target: editorTogglePlay() ends at startPlayback()/stopPlayback() (main.js:1617-1631), so registering Space as player-scoped and delegating to it keeps one transport. Two things must survive the move:
The MIDI-take finalize. editorTogglePlay() already routes to editorStopRecordMidi() when a take is live, so no extra guard is needed provided the handler stays a single call.
Risk to check with core: if the host's Space is an Electron menu accelerator rather than a renderer key handler, it fires in the main process before keydown and preventDefault() cannot stop it (shortcuts.js:424-429 documents exactly this trap for {role:'reload'} / CmdOrCtrl+R). Both would then toggle play and the transport would invert on every press. If core cannot suppress its own accelerator for a player-scoped plugin shortcut, the resolution is to drop the plugin's Space handler entirely and rely on the host's, keeping only the take-finalize as a separate plugin-editor shortcut on a different key. That is a host-capability question, not a design choice — see Open questions.
Escape vs the host's back-to-library. The resolution is structural: nothing here may be global, and Escape may not be player. All eight owners close editor chrome, so plugin-editor is correct, and the scope automatically stops the editor from claiming the key when #plugin-editor is not .active — which is exactly the case where the host's back-to-library must win. The second half of the resolution is subtler and is the part that would regress: today, when no modal is open and nothing is selected, onKeyDown falls through without calling preventDefault(), leaving Escape free for the host. A condition-only registration that always fires would start eating back-to-library from the editor screen. The migrated Escape must keep today's fall-through — the handler calls preventDefault() only when it actually consumed the key.
API gaps that block a faithful migration
Three shapes in onKeyDown have no registerShortcut equivalent as documented:
Claim-without-acting._editorSelectAllPolicyPure (input.js:1717) and the parts-view Delete rung (input.js:1778-1782) must suppress the browser/host default on a read-only surface while doing nothing. A condition decides whether the handler runs; it cannot express "I own this key so the host default must not fire".
Exclusive catch-all modality. The tab-preview lens, user-guide lens and tool palette swallow every key including unbound ones. There is no key to attach that to.
Ordered ownership. Gates 1-8 of Escape need a fixed priority.
Gaps 2 and 3 are the reason I would not start #38 until core confirms one of three shapes, which is also the fork raised in #10:
(a) accept the condition-re-derivation cost and keep the ladder in the plugin, registering each key separately;
(b) register one catch-all shortcut that calls into the dispatcher — satisfies the letter, not the goal;
(c)recommended: ask core for a priority/modal-ownership extension to registerShortcut — e.g. group/priority plus an explicit "this group swallows unclaimed keys while active". One core-side change makes (a) honest for all three gaps, and it is the only shape that keeps the ladder order load-bearing rather than incidental.
For #38 specifically the drum G/F/K rows are the easy part: their conditions are mutually exclusive with the profile dispatch, so per-key registration is safe and needs no core change. Delete needs gap 1. Space/Escape (#39) need gaps 2 and 3 plus the accelerator question.
Framing note on discoverability
The editor already has a discovery surface of its own: ? → _editorShowShortcutDiscovery (input.js:974-978) opens #editor-shortcut-panel, which renders the profile registry plus the Edit/View menus and the command palette. That covers the registry-driven commands. The keys above are the exact set the panel cannot show today, which is why they — not the profile tables — are the actual gap behind #10. Registering them for the host's ? panel is the fix; nothing here argues for re-registering the profile tables.
Open questions for core (cannot be answered from this repo — no vendored core docs)
Does a player- or global-scoped plugin shortcut suppress the host's own binding for the same key, or do both fire? Determines the Space resolution.
Is the host's Space an Electron menu accelerator? If so, preventDefault() is powerless (shortcuts.js:424-429 precedent).
Are the three documented scopes exactly plugin-editor / player / global, and does plugin-editor mean "#plugin-editor is .active"? I have assumed yes.
Are multiple registrations on one key evaluated in registration order, first-match-wins, all-of-them, or last-registered-wins? Determines whether the Delete/Escape ladders can be expressed as N registrations at all.
Does core plan a priority/modal-ownership extension (gap 3 above)?
Activity
pullfrog commentedon Oct 2, 2026
cchacs commentedon Oct 2, 2026
@pullfrog please work on this issue. It is the first sub-issue of #10: an audit and a written decision table, no behavior change. Post the table as a comment here. Do not start the dependent sub-issues.
Generated by Claude Code
cchacs commentedon Oct 2, 2026
@itsjustroxi this sub-issue is a decision table for #10 (editor keybinds), and the other keybind sub-issues depend on it. For each hardcoded key (Space, Delete, Backspace, Escape, G, F, K), decide the
window.registerShortcutscope (plugin-editor,playerorglobal) and its guard condition. Two need a particular call because the host already owns them: Space (play/pause on the player) and Escape (return to the library), so decide how the editor's own use coexists with the built-ins.Generated by Claude Code
pullfrog commentedon Oct 2, 2026
Audit: every key handled in
onKeyDown— action, guard, scope decisionNo behavior change in this issue. This is the decision record #38, #39 and #40 will implement.
Location:
onKeyDowninsrc/input.js:1610-2016, registered as adocumentkeydown listener atsrc/main.js:2308. It is not a flat list of bindings — it is an ordered ladder of ~20 mutually-exclusive gates whose order is load-bearing (see the "ladder order" column). Every row below is one key handled by a literale.keycomparison in that file; keys routed through the shortcut-profile tables (_editorDispatchFeedbackShortcut/_editorDispatchEofShortcut,src/input.js:1891-1892) are already data-driven and are not in scope here.Scope vocabulary (assumption — see Open questions)
plugin-editor— live only while#plugin-editorcarries.active. Correct for anything that closes or edits editor chrome (modals, panels, mode-specific verbs). Never claims the key outside the editor screen.player— belongs to the shared transport/timeline surface: playback and the verbs a DAW player owns (delete-selection, select-all). Used where the verb should stay live across the surfaces the host's player owns.global— survives screen changes. Recommendation: nothing from this file.The six keys named in #10
Spaceinput.js:1748-1752window.editorTogglePlay()→startPlayback()/stopPlayback(), oreditorStopRecordMidi()when a take is active (main.js:1617-1631)!e.target.matches('input, select, textarea'). Sits before the recording guard (:1760), the parts-view read-only gate (:1769) and the profile dispatch — so it is live in drum-edit, tempo-map, parts view and mid-takeplayer— transport, and the host already binds Space as player play/pauseDelete/Backspaceinput.js:1902-1986(ladder) +input.js:1778-1782(parts view):1907) → anchor (:1915) → handshape (:1928) → tone marker (:1952) → drum hits (:1968) → notes (:1979) → Tracks-overview region block (:1778)!typing. Rungs 2-6 add!S.drumEditMode && !S.tempoMapModeplus their own selection predicate; rungs 1, 6 add the mode flag; rung 7 addshost.partsViewRegionDelete()returningtrueplayer— one registration for the whole ladder (see "why one row")cutSelectiontoShift+Del(shortcuts.js:145), so plainDeletemust stay distinct — it doesEscapeinput.js:1621-1632,:1639-1650,:1660-1665,:1671-1680,:1845,:1849-1857,:1860-1867,:1879-1889main.js:939-968separately closes add-note, load and command-palette modals and callsstopImmediatePropagation()when one of them consumed the key;tempo-zones.js:278andanchor-resolve.js:395own Escape on their own document listenersplugin-editor, as one registration holding the ladder in today's order — neverglobalGinput.js:1790-1810_drumEditorToggleArticulation('g')— toggles ghost-note on selected drum hitsS.drumEditMode && S.drumSel.size && S.drumTab && !shiftKey && !ctrlKey && !metaKey && !altKey && !typing; falls through if no refs. Unreachable while recording (:1760)plugin-editorGistoggleSnap(FeedBack) andtoggleTempoMap(Logical,input.js:1705-1709). Mutually exclusive today via theS.drumEditModegate — a real win, the two conditions can never both be trueFinput.js:1790-1810_drumEditorToggleArticulation('f')— flamGplugin-editorFiseditFretin the FeedBack and EOF profiles. Same mutual exclusion viaS.drumEditModeKinput.js:1790-1810_drumEditorToggleArticulation('k')— choke, filtered to cymbal pieces only (drum.js:1180-1187, so it can no-op with a selection present)Gplugin-editorKiscyclePickDirection(FeedBack/EOF) andtoggleMetronomein the Logical profile (shortcuts.js:413). Same mutual exclusion, but this is the one row where registering both the drum verb and the profile verb as shortcuts would create a genuine ordering dependency — keep the drumKasplugin-editorand leavecyclePickDirection/toggleMetronomeowned by the profile registry for nowLadder order for
Escape(all inonKeyDown, this order is the behavior)::1621tab-preview lens — Escape closes, every other key swallowed:1639user-guide lens — identical policy:1660context/section menu — Escape only, so a dismiss can't also wipe the selection it was opened on:1671tool palette — Escape closes, everything else swallowed:1845tap-tempo run owns Enter/Escape until resolved:1849suggested-fit ghosts:1860barline multi-selection:1879note/drum selectionSteps 1-2 and 4 are swallow-all gates — no key equivalent exists for them (see API gaps).
The other hardcoded keys in
onKeyDown(not named in #38/#39 — flagging so nothing is lost)Ctrl/Cmd+Ainput.js:1596-1608(pure policy),1717-1746(dispatch)plugin-editorT/B/Ginput.js:1683-1710T, all profiles but EOF) / toggle Pencil (B, Cableton) / enter Tempo Map (G, Logical)!typing && !ctrl && !meta && !alt && !shift && !S.tempoMapMode && !S.drumEditMode && !S.partsViewMode && _recState !== 'recording', plus a profile check per rowplugin-editorA/Ninput.js:1803-1809plugin-editorShift+↑/Shift+↓input.js:1815-1823S.drumEditMode && S.drumSel.size && S.drumTab && shift && !ctrl && !meta && !alt && !typingplugin-editorAlt+←/→(+Shift, +Ctrl)input.js:1834-1841Alt+←/→, end onAlt+Shift+←/→, coarse step withCtrlS.barSel && altKey && !metaKey && !typingplayerInsertinput.js:1895-1900S.tempoMapMode && !typingplugin-editorEnterinput.js:1845;main.js:934-938_tapTempoHandleKeyowns it while a run is pendingplugin-editorCtrl+Alt+Z,Ctrl+Z,Ctrl+Y/Ctrl+Shift+Z,Ctrl+Dinput.js:1989-2015!typing;Ctrl+Dadditionally!S.drumEditMode && !S.tempoMapMode && S.sel.sizeplayer(undo/redo/duplicate)Why
DeleteandEscapeare one registration each, not one per rungRegistering 7
Deletehandlers with 7 conditions reproduces today's ladder only if the host evaluates them in registration order and stops at the first match — neither is guaranteed by the API as described in core's docs. The same is true for the 8Escapeowners, and there the overlap is real: with a suggestion ghost up and barlines selected, both:1849and:1860conditions are true. Recommendation: keep each ladder as oneregisterShortcutcall whose handler is today's ordered code, and expose one help-panel row per physical key ("Delete / Backspace — delete the current selection", "Escape — close the top layer, or clear the selection"). Seven rows for one key would be discoverable but wrong. The mode-specific variants already get documented in the plugin's own panel, which today renders the registry rows via_editorShortcutRowsPure(shortcuts.js:729-740) — see the framing note below.Collision resolutions (the two acceptance criteria)
Space vs the host's player play/pause. Same verb, same target:
editorTogglePlay()ends atstartPlayback()/stopPlayback()(main.js:1617-1631), so registeringSpaceasplayer-scoped and delegating to it keeps one transport. Two things must survive the move:editorTogglePlay()already routes toeditorStopRecordMidi()when a take is live, so no extra guard is needed provided the handler stays a single call.keydownandpreventDefault()cannot stop it (shortcuts.js:424-429documents exactly this trap for{role:'reload'}/CmdOrCtrl+R). Both would then toggle play and the transport would invert on every press. If core cannot suppress its own accelerator for aplayer-scoped plugin shortcut, the resolution is to drop the plugin's Space handler entirely and rely on the host's, keeping only the take-finalize as a separateplugin-editorshortcut on a different key. That is a host-capability question, not a design choice — see Open questions.Escape vs the host's back-to-library. The resolution is structural: nothing here may be
global, and Escape may not beplayer. All eight owners close editor chrome, soplugin-editoris correct, and the scope automatically stops the editor from claiming the key when#plugin-editoris not.active— which is exactly the case where the host's back-to-library must win. The second half of the resolution is subtler and is the part that would regress: today, when no modal is open and nothing is selected,onKeyDownfalls through without callingpreventDefault(), leaving Escape free for the host. Acondition-only registration that always fires would start eating back-to-library from the editor screen. The migrated Escape must keep today's fall-through — the handler callspreventDefault()only when it actually consumed the key.API gaps that block a faithful migration
Three shapes in
onKeyDownhave noregisterShortcutequivalent as documented:_editorSelectAllPolicyPure(input.js:1717) and the parts-viewDeleterung (input.js:1778-1782) must suppress the browser/host default on a read-only surface while doing nothing. Aconditiondecides whether the handler runs; it cannot express "I own this key so the host default must not fire".Escapeneed a fixed priority.Gaps 2 and 3 are the reason I would not start #38 until core confirms one of three shapes, which is also the fork raised in #10:
condition-re-derivation cost and keep the ladder in the plugin, registering each key separately;registerShortcut— e.g.group/priorityplus an explicit "this group swallows unclaimed keys while active". One core-side change makes (a) honest for all three gaps, and it is the only shape that keeps the ladder order load-bearing rather than incidental.For #38 specifically the drum
G/F/Krows are the easy part: their conditions are mutually exclusive with the profile dispatch, so per-key registration is safe and needs no core change.Deleteneeds gap 1.Space/Escape(#39) need gaps 2 and 3 plus the accelerator question.Framing note on discoverability
The editor already has a discovery surface of its own:
?→_editorShowShortcutDiscovery(input.js:974-978) opens#editor-shortcut-panel, which renders the profile registry plus the Edit/View menus and the command palette. That covers the registry-driven commands. The keys above are the exact set the panel cannot show today, which is why they — not the profile tables — are the actual gap behind #10. Registering them for the host's?panel is the fix; nothing here argues for re-registering the profile tables.Open questions for core (cannot be answered from this repo — no vendored core docs)
player- orglobal-scoped plugin shortcut suppress the host's own binding for the same key, or do both fire? Determines the Space resolution.preventDefault()is powerless (shortcuts.js:424-429precedent).plugin-editor/player/global, and doesplugin-editormean "#plugin-editoris.active"? I have assumed yes.Delete/Escapeladders can be expressed as N registrations at all.Recommended sequencing
Delete,Backspace,G,F,K): proceed once Q4 is answered.G/F/Kare independent today and can land first as the proof.Delete/Backspaceas one ladder registration, guarded by the current rungs.Space,Escape): hold until Q1, Q2, Q3 and Q5 land. These are the collision-risk pair and the ones the issue calls riskier.main.js:2308stays until all six have moved, andmain.js:939-968,tempo-zones.js:278andanchor-resolve.js:395own Escape on separate listeners that Keybinds 4/4: remove the document-level key listener and verify the help panel #40 must account for too.big-pickle(free) | 𝕏feat(keybinds): audit hardcoded keys in onKeyDown (#37) (#60)