Skip to content

Keybinds 1/4: audit the hardcoded keys in onKeyDown and decide each key's scope #37

Closed

Description

@cchacs

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.

Out of scope

Doing the migration (2/4 and 3/4).

Activity

  1. pullfrog commented on Oct 2, 2026

    @pullfrog
  2. cchacs commented on Oct 2, 2026

    @cchacs
    CollaboratorAuthor

    @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

  3. cchacs commented on Oct 2, 2026

    @cchacs
    CollaboratorAuthor

    @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.


    Generated by Claude Code

  4. pullfrog commented on Oct 2, 2026

    @pullfrog

    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.

    The six keys named in #10

    Key Site Action Guards (verbatim, today) Scope Collision
    Space input.js:1748-1752 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 Direct, with #10. Resolution below
    Delete / Backspace input.js:1902-1986 (ladder) + input.js:1778-1782 (parts view) 7 rungs, first match wins: tempo barlines (:1907) → anchor (:1915) → handshape (:1928) → tone marker (:1952) → drum hits (:1968) → notes (:1979) → Tracks-overview region block (:1778) Every rung requires !typing. Rungs 2-6 add !S.drumEditMode && !S.tempoMapMode plus their own selection predicate; rungs 1, 6 add the mode flag; rung 7 adds host.partsViewRegionDelete() returning true player — one registration for the whole ladder (see "why one row") None known in the host. In-plugin: EOF binds cutSelection to Shift+Del (shortcuts.js:145), so plain Delete must stay distinct — it does
    Escape 8 owners — input.js:1621-1632, :1639-1650, :1660-1665, :1671-1680, :1845, :1849-1857, :1860-1867, :1879-1889 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 Direct, with #10. Resolution below
    G input.js:1790-1810 _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):

    1. :1621 tab-preview lens — Escape closes, every other key swallowed
    2. :1639 user-guide lens — identical policy
    3. :1660 context/section menu — Escape only, so a dismiss can't also wipe the selection it was opened on
    4. :1671 tool palette — Escape closes, everything else swallowed
    5. :1845 tap-tempo run owns Enter/Escape until resolved
    6. :1849 suggested-fit ghosts
    7. :1860 barline multi-selection
    8. :1879 note/drum selection

    Steps 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)

    Keys Site Action Guard Scope
    Ctrl/Cmd+A input.js:1596-1608 (pure policy), 1717-1746 (dispatch) Select All → editor objects, or yields to native text-editor Select All Runs before the recording and parts-view guards, which suppress the browser default without selecting objects on a read-only surface plugin-editor
    T / B / G input.js:1683-1710 Open tool palette (T, 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 row plugin-editor
    A / N input.js:1803-1809 Drum velocity quick-set: accent 115 / normal 100 Inside the drum-edit guard block plugin-editor
    Shift+↑ / Shift+↓ input.js:1815-1823 Drum velocity ±10 S.drumEditMode && S.drumSel.size && S.drumTab && shift && !ctrl && !meta && !alt && !typing plugin-editor
    Alt+←/→ (+Shift, +Ctrl) input.js:1834-1841 Nudge loop edge: start on Alt+←/→, end on Alt+Shift+←/→, coarse step with Ctrl S.barSel && altKey && !metaKey && !typing player
    Insert input.js:1895-1900 Mark barline at cursor S.tempoMapMode && !typing plugin-editor
    Enter input.js:1845; main.js:934-938 Resolve tap-tempo run / confirm add-note dialog _tapTempoHandleKey owns it while a run is pending plugin-editor
    Ctrl+Alt+Z, Ctrl+Z, Ctrl+Y/Ctrl+Shift+Z, Ctrl+D input.js:1989-2015 Undo to checkpoint / undo / redo / duplicate !typing; Ctrl+D additionally !S.drumEditMode && !S.tempoMapMode && S.sel.size player (undo/redo/duplicate)

    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:

    1. 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".
    2. 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.
    3. 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)

    1. 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.
    2. Is the host's Space an Electron menu accelerator? If so, preventDefault() is powerless (shortcuts.js:424-429 precedent).
    3. Are the three documented scopes exactly plugin-editor / player / global, and does plugin-editor mean "#plugin-editor is .active"? I have assumed yes.
    4. 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.
    5. Does core plan a priority/modal-ownership extension (gap 3 above)?

    Recommended sequencing

    Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions