diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6c007..7f18695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tests, older embeds) the `onKeyDown` fallback still performs every delete and toggle exactly as before. +- **Keybinds: Space and Escape register with the Host shortcut service, and the + document listener is down to the keys that cannot be shortcuts (#39/#40).** + Play/Pause (Space) and the Escape dismissal ladder join the editing keys on + `window.registerShortcut({ ... scope: 'plugin-editor' })`, so the global `?` + help panel lists them with a description. Both keep today's behavior: Space is + live mid-take (it finalizes the take) and ignored while typing, under a + read-only lens, or with the tool palette open; Escape closes the tab preview + and user guide, the context menu, the tool palette, a pending tap-tempo run, + suggested-fit ghosts, and a barline or note/drum selection in that order, + yielding to the overlays that own Escape through their own handler (the + in-app text/choice prompts, the anchor sweep and a tempo-zones proposal) and + preserving the read-only Tracks overview and mid-take guards, deliberately + leaving an unowned Escape to the Host's back-to-library (it calls + `preventDefault()` only when it consumed the key). `onKeyDown` now keeps only + the gates that genuinely cannot be registry rows — the swallow-all read-only + lenses and palette, the Select All policy, the recording gate and the + profile-dependent plain tool keys — plus a fallback for the migrated keys that + only runs when the Host registry is unavailable (bare unit suites, older + embeds). No key is ever handled twice: that fallback stands down the moment + `editorShortcutState.registered` flips true. + ### Security - **Added an XML entity-expansion ("billion laughs") guard.** `ET.parse()` diff --git a/src/input.js b/src/input.js index f284ec2..bf0a030 100644 --- a/src/input.js +++ b/src/input.js @@ -1908,26 +1908,150 @@ export function _editorDrumArticulation(kind, e) { return true; } +// Escape — the ordered first-match ladder, shared by onKeyDown's fallback path +// and the Host `registerShortcut` handler (#39). Returns true when it consumed +// the key (the caller then preventDefault()s); false leaves Escape downstream — +// for the Host that is its own back-to-library. Owners, top to bottom, in the +// pre-#39 onKeyDown order: the read-only Tab-preview and User-guide lenses, the +// right-click context/section menu, the tool palette, then — past the parts-view +// gate and the recording gate that used to sit at `_recState === 'recording'` — +// a pending tap-tempo run, suggested-fit ghosts, a barline multi-selection, and +// finally the note/drum selection. Only the pre-existing lens/menu/palette +// closes ignore the focused element; the mode-specific rungs below keep the +// original `!typing` guard, so it sits after the tap-tempo run, not at the top. +// The parts-view and recording gates are load-bearing: a read-only Tracks +// overview and a live take ignore Escape exactly as they did before. +export function _editorEscape(e) { + if (!e || (e.key !== 'Escape' && e.code !== 'Escape')) return false; + if (_editorEscapeOverlays()) return true; + if (S.partsViewMode) return false; + if (_recState === 'recording') return false; + if (_tapTempoHandleKey(e)) return true; + if (_editorIsTypingTarget(e)) return false; + if (_editorEscapeTempoState()) return true; + return _editorEscapeClearSelection(); +} + +// Escape rung 1 — the modal/overlay surfaces that sit above the chart and own +// Escape through their own close: the read-only Tab-preview and User-guide +// lenses, the right-click context/section menu, and the click tool palette. +// These ignore the focused element, matching the pre-#39 onKeyDown order. +function _editorEscapeOverlays() { + const tabPreview = document.getElementById('editor-tab-preview-modal'); + if (tabPreview && !tabPreview.classList.contains('hidden')) { + window.editorHideTabPreview(); + return true; + } + const guide = document.getElementById('editor-user-guide-modal'); + if (guide && !guide.classList.contains('hidden')) { + window.editorToggleUserGuide(false); + return true; + } + const ctxMenu = document.getElementById('editor-context-menu'); + if (ctxMenu && !ctxMenu.classList.contains('hidden')) { + hideContextMenu(); + return true; + } + if (editorToolPaletteOpen()) { + editorCloseToolPalette(); + return true; + } + return false; +} + +// Escape rung 2 — tempo-map mode: suggested-fit ghosts win over a barline +// multi-selection (proposal-only state must never fall through to anything +// destructive). Returns true when it consumed the key. +function _editorEscapeTempoState() { + if (S.tempoMapMode && _suggestActive()) { + _suggestDismiss(); + host.updateBPMDisplay(); // retire the Accept Whole Fit button too + host.draw(); + setStatus('Suggestions dismissed'); + return true; + } + if (S.tempoMapMode && S.tempoSelMulti && S.tempoSelMulti.size) { + S.tempoSelMulti.clear(); + host.draw(); + setStatus('Selection cleared'); + return true; + } + return false; +} + +// Escape rung 3 — the standard DAW "clear selection" gesture for notes/drums, +// layered under every earlier owner. Non-destructive (selection is not undo +// state), so this touches no history and no-ops when nothing is selected. +function _editorEscapeClearSelection() { + if (!S.tempoMapMode && (S.sel.size || (S.drumSel && S.drumSel.size))) { + S.sel.clear(); + if (S.drumSel) S.drumSel.clear(); + _renderInspector(); + host.draw(); + setStatus('Selection cleared'); + return true; + } + return false; +} + +// Space — play/pause (also finalizes an in-flight take). Shared by onKeyDown's +// fallback path and the Host `registerShortcut` handler (#39). The read-only +// lenses and the tool palette swallow it and a text field ignores it, but it is +// deliberately live mid-take because `window.editorTogglePlay` routes the stop +// through `editorStopRecordMidi` — the recording check the editing keys carry is +// absent here. Returns true when it acted. +export function _editorTogglePlayShortcut(e) { + // The Host matches special keys on `e.code` and may hand the handler either + // the raw event (`e.key === ' '`) or a token-shaped one, so accept both. + if (!e || (e.key !== ' ' && e.key !== 'Space' && e.code !== 'Space')) return false; + if (_editorIsTypingTarget(e)) return false; + if (typeof document !== 'undefined') { + const screen = document.getElementById('plugin-editor'); + if (!screen || !screen.classList.contains('active')) return false; + const tabPreview = document.getElementById('editor-tab-preview-modal'); + if (tabPreview && !tabPreview.classList.contains('hidden')) return false; + const guide = document.getElementById('editor-user-guide-modal'); + if (guide && !guide.classList.contains('hidden')) return false; + } + let paletteOpen = false; + try { paletteOpen = editorToolPaletteOpen(); } catch (_) { paletteOpen = false; } + if (paletteOpen) return false; + _editorRunEofCommand('togglePlay'); + return true; +} + +// The document-level keydown fallback. Every key covered by the Host shortcut +// migration (#37–#40) is registered through `window.registerShortcut` when the +// Host API is present (shortcut-registry.js) and stands down here by reading +// `editorShortcutState.registered`; this listener then covers the compatibility +// path (a Host without the API / the unit suites). The branches that remain are +// the ones that genuinely cannot be registry rows: mode/surface policy that +// applies to every key (read-only lens swallow, Select All, recording), and the +// plain-key tool keys whose meaning depends on the active profile. export function onKeyDown(e) { // Only handle when editor screen is visible const screen = document.getElementById('plugin-editor'); if (!screen || !screen.classList.contains('active')) return; + // Escape — the whole ladder lives in _editorEscape, shared with the Host + // `registerShortcut` handler (#39). When the Host registry owns Escape that + // handler runs it instead; this fallback covers a Host without the API (and + // the unit suites). An unconsumed Escape is left to the Host's + // back-to-library, so we deliberately do NOT preventDefault on false. + if (e.key === 'Escape') { + if (!editorShortcutState.registered && _editorEscape(e)) e.preventDefault(); + return; + } + // Read-only Tab preview is a modal proofreading lens — while it's open // NO editor shortcut (fret digits, f, arrows, Delete, transport …) may // reach the chart hidden behind it, or the "read-only" preview would - // silently mutate the arrangement and pollute undo/redo. Escape closes - // it; every other key is swallowed. Mirrors the partsViewMode gate and - // must sit before the spacebar/transport handler below. + // silently mutate the arrangement and pollute undo/redo. Escape closes it + // (now the shared Escape ladder above); every other key is swallowed. + // Mirrors the partsViewMode gate and sits before the spacebar handler. const _tabPreviewModal = document.getElementById('editor-tab-preview-modal'); const _tabPreviewOpen = !!(_tabPreviewModal && !_tabPreviewModal.classList.contains('hidden')); - const _tabPreviewAction = _tabPreviewKeyPolicyPure(_tabPreviewOpen, e.key); - if (_tabPreviewAction === 'close') { - e.preventDefault(); - window.editorHideTabPreview(); - return; - } - if (_tabPreviewAction === 'swallow') { + if (_tabPreviewKeyPolicyPure(_tabPreviewOpen, e.key) === 'swallow') { e.preventDefault(); return; } @@ -1935,40 +2059,21 @@ export function onKeyDown(e) { // The User Guide (Help ▸ User Guide) is the same class of read-only modal // lens as the Tab preview: while it's open NO editor shortcut may reach // the chart hidden behind it (a stray H/2/Delete would silently mutate the - // arrangement and pollute undo/redo). Same policy — Escape closes, every - // other key is swallowed — and it too must sit before the spacebar handler. + // arrangement and pollute undo/redo). Same policy — Escape closes (the + // shared ladder above), every other key is swallowed — and it too must sit + // before the spacebar handler. const _guideModal = document.getElementById('editor-user-guide-modal'); const _guideOpen = !!(_guideModal && !_guideModal.classList.contains('hidden')); - const _guideAction = _tabPreviewKeyPolicyPure(_guideOpen, e.key); - if (_guideAction === 'close') { - e.preventDefault(); - window.editorToggleUserGuide(false); - return; - } - if (_guideAction === 'swallow') { + if (_tabPreviewKeyPolicyPure(_guideOpen, e.key) === 'swallow') { e.preventDefault(); return; } - // The right-click context / section menu owns Escape to dismiss itself, and - // it is closed HERE — not by main.js's import-time Escape listener — for the - // same reason the User Guide close lives here: this runs BEFORE the note / - // drum deselect branch below, so Escape-to-dismiss a menu can't ALSO wipe - // the selection the menu was opened on (right-click keeps/sets a selection, - // then Escape would clear it if both the menu-close and the deselect fired - // on one keypress). Only Escape is claimed; every other key still passes - // through to the chart behind the menu, exactly as before. - const _ctxMenu = document.getElementById('editor-context-menu'); - if (e.key === 'Escape' && _ctxMenu && !_ctxMenu.classList.contains('hidden')) { - e.preventDefault(); - hideContextMenu(); - return; - } // ── Click tools (CLICK-TOOLS-DESIGN.md) ────────────────────────── // While the tool palette is open it owns the keyboard: a tool key picks // and closes, second-T runs the per-profile call (FeedBack/Cableton → // Tempo Map, the old plain-T habit; Logical → Pointer, Logic-exact), - // Escape closes, everything else is swallowed. + // everything else is swallowed. Escape closes it through the shared ladder. if (editorToolPaletteOpen()) { e.preventDefault(); const act = _editorPaletteKeyActionPure(editorShortcutProfile, e.key); @@ -2049,10 +2154,12 @@ export function onKeyDown(e) { // Spacebar: play/pause (also stops recording). Claimed here before the // profile dispatch — every profile keeps Space on transport, and the // registry row (togglePlay) is what advertises it in the `?` panel and - // the command palette. + // the command palette. When the Host registry owns the key (#39) its + // handler runs the same command, so this listener must not also fire. if (e.key === ' ' && !e.target.matches('input, select, textarea')) { + if (editorShortcutState.registered) return; e.preventDefault(); - return _editorRunEofCommand('togglePlay'); + return _editorTogglePlayShortcut(e); } // Block all mutating shortcuts while a take is active so mid-take @@ -2214,54 +2321,11 @@ export function onKeyDown(e) { } } - // A pending tap-tempo run owns Enter/Escape until resolved — checked - // before the profile dispatchers so neither can steal the keys. + // A pending tap-tempo run owns Enter until resolved — checked before the + // profile dispatchers so neither can steal the key. (Its Escape half now + // lives in the shared Escape ladder at the top of onKeyDown.) if (_tapTempoHandleKey(e)) return; - // Suggested-fit ghosts own Escape while showing (proposal-only state — - // dismissal must never fall through to anything destructive). - if (e.key === 'Escape' && S.tempoMapMode && _suggestActive() - && !e.target.matches('input, select, textarea')) { - e.preventDefault(); - _suggestDismiss(); - host.updateBPMDisplay(); // retire the Accept Whole Fit button too - host.draw(); - setStatus('Suggestions dismissed'); - return; - } - // Escape clears a barline multi-selection (PR 5a) — layered UNDER the - // suggest-dismiss above, so ghosts always own Escape first. - if (e.key === 'Escape' && S.tempoMapMode && S.tempoSelMulti && S.tempoSelMulti.size - && !e.target.matches('input, select, textarea')) { - e.preventDefault(); - S.tempoSelMulti.clear(); - host.draw(); - setStatus('Selection cleared'); - return; - } - // Escape drops the note / drum selection — the standard DAW "clear - // selection" gesture, absent until now (Escape only ever fired in - // tempo-map mode). Layered UNDER every earlier Escape owner: the read-only - // Tab-preview / User-Guide lenses (handled at the top of onKeyDown, which - // return before reaching here) and the tempo-map suggest-dismiss / - // barline-clear branches just above (which return in tempo mode; guarded - // out here by !S.tempoMapMode). The transient modals (add-note, context - // menu, load, palette) are closed by main.js's import-time keydown - // listener, which runs first. Non-destructive: selection is not undo - // state, so this touches no history; it no-ops (falls through) when - // nothing is selected, keeping Escape free for anything downstream. - if (e.key === 'Escape' && !S.tempoMapMode - && !e.target.matches('input, select, textarea') - && (S.sel.size || (S.drumSel && S.drumSel.size))) { - e.preventDefault(); - S.sel.clear(); - if (S.drumSel) S.drumSel.clear(); - _renderInspector(); - host.draw(); - setStatus('Selection cleared'); - return; - } - if (_editorDispatchFeedbackShortcut(e)) return; if (_editorDispatchEofShortcut(e)) return; diff --git a/src/main.js b/src/main.js index 45ca32d..d939a1a 100644 --- a/src/main.js +++ b/src/main.js @@ -2392,10 +2392,10 @@ function init() { canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('contextmenu', onContextMenu); _globalListeners.add(document, 'keydown', onKeyDown); - // Hand the editing keys (Delete/Backspace, drum G/F/K) to the Host's - // shortcut registry so they surface in the `?` panel and stay scoped to - // this screen (#38). No-op without the Host API; input.js keeps its own - // fallback path while `editorShortcutState.registered` is false. + // Hand the editor's shortcuts (Delete/Backspace, drum G/F/K, Space, Escape) + // to the Host's shortcut registry so they surface in the `?` panel and stay + // scoped to this screen (#38/#39/#40). No-op without the Host API; input.js + // keeps its own fallback path while `editorShortcutState.registered` is false. registerEditorShortcuts(); // Prevent middle-click paste diff --git a/src/shortcut-registry.js b/src/shortcut-registry.js index 978893f..8c8e01a 100644 --- a/src/shortcut-registry.js +++ b/src/shortcut-registry.js @@ -1,10 +1,12 @@ -// Host shortcut bridge (#38). Registers the editor's hardcoded editing keys -// (Delete / Backspace and the drum G / F / K articulation toggles) with the -// Host's `window.registerShortcut` API so they surface in the global `?` help -// panel and are scoped to this screen (`plugin-editor`). The Host dispatches -// them independently of the plugin's own `document` keydown listener, so each -// handler re-checks the read-only-lens / recording / mode guards `onKeyDown` -// applies — the registration is not a licence to act behind a modal. +// Host shortcut bridge (#38, extended by #39). Registers the editor's +// hardcoded keys with the Host's `window.registerShortcut` API so they surface +// in the global `?` help panel and are scoped to this screen (`plugin-editor`): +// the editing keys (Delete / Backspace and the drum G / F / K articulation +// toggles), plus the transport Space and the dismissal Escape. The Host +// dispatches them independently of the plugin's own `document` keydown +// listener, so each handler re-checks the read-only-lens / recording / mode +// guards `onKeyDown` applies — the registration is not a licence to act behind +// a modal. // // Degrades to a no-op under node / a Host without the API: // `registerEditorShortcuts` only flips `editorShortcutState.registered` when @@ -19,14 +21,18 @@ import { _recState } from './midi-record.js'; import { _editorIsTypingTarget } from './shortcuts.js'; import { editorToolPaletteOpen } from './tools.js'; import { editorShortcutState } from './shortcut-state.js'; -import { _editorDeleteSelection, _editorDrumArticulation } from './input.js'; +import { _zonesActive } from './tempo-zones.js'; +import { _sweepActive } from './anchor-resolve.js'; +import { + _editorDeleteSelection, _editorDrumArticulation, _editorEscape, _editorTogglePlayShortcut, +} from './input.js'; const SCOPE = 'plugin-editor'; // { key, handle } for every registration made by the live injection. let _registered = []; -function _lensOpen(id) { +function _modalShown(id) { if (typeof document === 'undefined') return false; const el = document.getElementById(id); return !!el && !el.classList.contains('hidden'); @@ -42,7 +48,7 @@ function _editorShortcutAllowed(e) { if (typeof document !== 'undefined') { const screen = document.getElementById('plugin-editor'); if (!screen || !screen.classList.contains('active')) return false; - if (_lensOpen('editor-tab-preview-modal') || _lensOpen('editor-user-guide-modal')) return false; + if (_modalShown('editor-tab-preview-modal') || _modalShown('editor-user-guide-modal')) return false; } let paletteOpen = false; try { paletteOpen = editorToolPaletteOpen(); } catch (_) { paletteOpen = false; } @@ -78,12 +84,40 @@ function _onDrumShortcut(kind) { }; } +// Space — transport. `_editorTogglePlayShortcut` carries the screen / typing / +// lens / palette guard itself (and no recording gate: Space finalizes a take), +// so the handler only keeps modifier chords out of the plain-key registration. +function _onSpaceShortcut(e) { + if (_hasModifier(e)) return; + if (_editorTogglePlayShortcut(e)) e.preventDefault(); +} + +// Escape — the dismissal ladder. It does NOT use `_editorShortcutAllowed`: that +// guard bails while a read-only lens is open, but closing that lens is exactly +// Escape's job. It must however yield to every overlay that owns Escape through +// its OWN handler: the host-owned transient modals (add-note / load / command +// palette, closed by main.js's import-time listener) and the in-app prompts +// (ui.js) plus the anchor sweep and tempo-zones proposal, which took the key +// via a capture-phase `stopPropagation()` listener that used to keep onKeyDown +// out — the host dispatches independently of DOM propagation, so this handler +// is now the one that must not clear the selection behind them. +function _onEscapeShortcut(e) { + if (_hasModifier(e)) return; + if (_modalShown('editor-add-note-dialog') || _modalShown('editor-load-modal') + || _modalShown('editor-command-palette') || _modalShown('editor-text-prompt') + || _modalShown('editor-choice-prompt')) return; + if (_zonesActive() || _sweepActive()) return; + if (_editorEscape(e)) e.preventDefault(); +} + const _SHORTCUT_SPECS = [ { key: 'Delete', description: 'Delete the current selection', handler: _onDeleteShortcut }, { key: 'Backspace', description: 'Delete the current selection', handler: _onDeleteShortcut }, { key: 'g', description: 'Toggle ghost notes on the selected drum hits', handler: _onDrumShortcut('g') }, { key: 'f', description: 'Toggle flam on the selected drum hits', handler: _onDrumShortcut('f') }, { key: 'k', description: 'Toggle choke on the selected cymbals', handler: _onDrumShortcut('k') }, + { key: 'Space', description: 'Play / pause', handler: _onSpaceShortcut }, + { key: 'Escape', description: 'Close the top layer, or clear the selection', handler: _onEscapeShortcut }, ]; export function registerEditorShortcuts() { diff --git a/tests/keybind_registration.test.mjs b/tests/keybind_registration.test.mjs index af09f74..6462c37 100644 --- a/tests/keybind_registration.test.mjs +++ b/tests/keybind_registration.test.mjs @@ -1,15 +1,24 @@ /* - * Keybind registration (#38) — the editing keys (Delete / Backspace, drum - * G / F / K) handed to the Host's `window.registerShortcut` API. + * Keybind registration (#38/#39) — the editor's keys handed to the Host's + * `window.registerShortcut` API. * * Asserts, at the behavior level: - * - the five keys register once each, scoped to `plugin-editor`, with a - * description (so they surface in the global `?` panel), + * - the seven keys (Delete / Backspace, drum G / F / K, Space, Escape) + * register once each, scoped to `plugin-editor`, with a description (so + * they surface in the global `?` panel), * - no registration (and no crash) when the Host API is absent, * - the handlers re-check the typing-target and read-only-lens guards, * - the Delete ladder is shared: notes / parts-view region / tempo rungs all * run through the registered handler, * - G / F / K toggle drum articulation through the registered handler, + * - Space runs transport through the registered handler (live mid-take) and + * bails in a text field / under a lens / with the palette open, + * - Escape runs the shared dismissal ladder through the registered handler: + * lens close, menu close (selection intact), ghost-before-barline + * precedence, the parts-view/prompt guards, and no preventDefault when + * nothing owns it (so the Host's back-to-library survives), + * - with the registry active, `onKeyDown` stands down for every migrated key + * (no double-handling), * - teardown calls every returned unregister handle, * - without the host registry, onKeyDown's fallback path still acts. * @@ -19,7 +28,40 @@ import { test } from 'node:test'; import assert from 'node:assert'; // ── DOM/global stubs (BEFORE any src import) ──────────────────────────────── -const _lensHidden = { 'editor-tab-preview-modal': true, 'editor-user-guide-modal': true }; +// id -> whether it currently carries the 'hidden' class. `_modalShown` (and +// hideContextMenu) read/write this so the Escape ladder sees the real state. +const _hidden = { + 'editor-tab-preview-modal': true, + 'editor-user-guide-modal': true, + 'editor-context-menu': true, + 'editor-add-note-dialog': true, + 'editor-load-modal': true, + 'editor-command-palette': true, + 'editor-tool-palette': true, + 'editor-text-prompt': true, + 'editor-choice-prompt': true, +}; +const _overlayEl = (id) => ({ + id, + classList: { + add(c) { if (c === 'hidden') _hidden[id] = true; }, + remove(c) { if (c === 'hidden') _hidden[id] = false; }, + toggle(c, on) { _hidden[id] = on === undefined ? !_hidden[id] : !!on; }, + contains: (c) => (c === 'hidden' ? !!_hidden[id] : false), + }, +}); +// The tool palette is the one overlay _renderPalette writes rows into. +const _paletteRows = { innerHTML: '' }; +const _toolPaletteEl = { + id: 'editor-tool-palette', style: {}, offsetHeight: 220, + classList: { + add(c) { if (c === 'hidden') _hidden['editor-tool-palette'] = true; }, + remove(c) { if (c === 'hidden') _hidden['editor-tool-palette'] = false; }, + toggle() {}, + contains: (c) => (c === 'hidden' ? !!_hidden['editor-tool-palette'] : false), + }, + querySelector: () => _paletteRows, +}; const _els = {}; const _genericEl = (id) => (_els[id] ||= { id, disabled: false, value: '', textContent: '', innerHTML: '', style: {}, @@ -34,7 +76,8 @@ const _genericEl = (id) => (_els[id] ||= { globalThis.document = globalThis.document || { getElementById(id) { if (id === 'plugin-editor') return { classList: { contains: (c) => c === 'active' } }; - if (id in _lensHidden) return { classList: { contains: (c) => (c === 'hidden' ? _lensHidden[id] : false) } }; + if (id === 'editor-tool-palette') return _toolPaletteEl; + if (id in _hidden) return _overlayEl(id); return _genericEl(id); }, createElement(tag) { @@ -55,9 +98,17 @@ globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setI globalThis.requestAnimationFrame = globalThis.requestAnimationFrame || (() => 0); globalThis.cancelAnimationFrame = globalThis.cancelAnimationFrame || (() => {}); +// The window entry points the Escape ladder / Space call. Recorders only. +const winCalls = { hideTabPreview: 0, toggleGuide: [], togglePlay: 0 }; +window.editorHideTabPreview = () => { winCalls.hideTabPreview++; }; +window.editorToggleUserGuide = (force) => { winCalls.toggleGuide.push(force); }; +window.editorTogglePlay = () => { winCalls.togglePlay++; }; + const { registerEditorShortcuts, unregisterEditorShortcuts } = await import('../src/shortcut-registry.js'); const { editorShortcutState } = await import('../src/shortcut-state.js'); const { _editorDeleteSelection, onKeyDown } = await import('../src/input.js'); +const { _suggestCompute, _suggestActive, _suggestDismiss } = await import('../src/tempo-suggest.js'); +const { editorOpenToolPalette, editorCloseToolPalette, editorToolPaletteOpen } = await import('../src/tools.js'); const { editorSetShortcutProfile } = await import('../src/shortcuts.js'); const { S } = await import('../src/state.js'); const { setHostHooks } = await import('../src/host.js'); @@ -110,6 +161,26 @@ function ev(key, extra = {}) { }; } +// A 12-bar tempo grid at 120 BPM (beat = 0.5s) plus a uniform 16th-note onset +// train at 126 BPM — the fixture shape tests/tempo_suggest.test.mjs uses to +// make `_suggestCompute` propose ghosts. +function grid(bars, bpm) { + const beat = 60 / bpm; + const beats = []; + for (let m = 0; m < bars; m++) { + for (let b = 0; b < 4; b++) { + beats.push({ time: (m * 4 + b) * beat, measure: b === 0 ? m + 1 : 0 }); + } + } + return beats; +} +function onsetsAllBeats(bpm, bars) { + const beat = 60 / bpm; + const out = []; + for (let k = 0; k < bars * 4; k++) out.push({ t: k * beat, s: k % 4 === 0 ? 0.9 : 0.75 }); + return out; +} + function startRegistration() { unregisterEditorShortcuts(); registrations = []; @@ -118,16 +189,22 @@ function startRegistration() { hostCalls.updateStatus = 0; hostCalls.regionDelete = 0; hostCalls.regionDeleteResult = true; + winCalls.hideTabPreview = 0; + winCalls.toggleGuide = []; + winCalls.togglePlay = 0; + for (const id of Object.keys(_hidden)) _hidden[id] = true; + editorCloseToolPalette(); + _suggestDismiss(); registerEditorShortcuts(); const byKey = (k) => registrations.find(r => r.key === k); return byKey; } // ── Registration surface ──────────────────────────────────────────────────── -test('registers the five editing keys, scoped to plugin-editor', () => { +test('registers the seven keys, scoped to plugin-editor', () => { const byKey = startRegistration(); - assert.strictEqual(registrations.length, 5, 'exactly five registrations'); - for (const k of ['Delete', 'Backspace', 'g', 'f', 'k']) { + assert.strictEqual(registrations.length, 7, 'exactly seven registrations'); + for (const k of ['Delete', 'Backspace', 'g', 'f', 'k', 'Space', 'Escape']) { const r = byKey(k); assert.ok(r, `key ${k} registered`); assert.strictEqual(r.scope, 'plugin-editor'); @@ -136,6 +213,8 @@ test('registers the five editing keys, scoped to plugin-editor', () => { } assert.strictEqual(byKey('Delete').description, 'Delete the current selection'); assert.strictEqual(byKey('Backspace').description, 'Delete the current selection'); + assert.strictEqual(byKey('Space').description, 'Play / pause'); + assert.strictEqual(byKey('Escape').description, 'Close the top layer, or clear the selection'); assert.strictEqual(editorShortcutState.registered, true); }); @@ -159,7 +238,7 @@ test('teardown calls every returned unregister handle', () => { unregisterEditorShortcuts(); assert.deepStrictEqual( unregisterCalls.slice().sort(), - ['Backspace', 'Delete', 'f', 'g', 'k'], + ['Backspace', 'Delete', 'Escape', 'Space', 'f', 'g', 'k'], ); assert.strictEqual(editorShortcutState.registered, false); }); @@ -248,6 +327,197 @@ test('registered drum keys ignore modifier chords', () => { assert.strictEqual(execs, 0, 'Ctrl+F / Shift+G stay with the profile dispatch'); }); +// ── Space through the registered handler ───────────────────────────────────── +test('Space handler toggles the transport on the editor screen', () => { + const byKey = startRegistration(); + resetS(); + winCalls.togglePlay = 0; + const e = ev(' '); + byKey('Space').handler(e); + assert.strictEqual(winCalls.togglePlay, 1, 'Space runs togglePlay'); + assert.ok(e._pd, 'the consumed key is preventDefault-ed'); +}); + +test('Space handler bails in a text field, under a lens, and with the palette open', () => { + const byKey = startRegistration(); + resetS(); + winCalls.togglePlay = 0; + byKey('Space').handler(ev(' ', { target: { matches: (sel) => String(sel).includes('input') } })); + assert.strictEqual(winCalls.togglePlay, 0, 'typing target'); + + _hidden['editor-user-guide-modal'] = false; + byKey('Space').handler(ev(' ')); + assert.strictEqual(winCalls.togglePlay, 0, 'read-only lens swallows transport'); + _hidden['editor-user-guide-modal'] = true; + + editorOpenToolPalette('feedback'); + byKey('Space').handler(ev(' ')); + assert.strictEqual(winCalls.togglePlay, 0, 'palette is modal — Space stays out'); + editorCloseToolPalette(); +}); + +test('Space handler ignores modifier chords', () => { + const byKey = startRegistration(); + resetS(); + const e = ev(' ', { ctrlKey: true }); + byKey('Space').handler(e); + assert.strictEqual(winCalls.togglePlay, 0); + assert.strictEqual(e._pd, false); +}); + +// ── Escape through the registered handler ──────────────────────────────────── +test('Escape handler closes the read-only lens: tab preview first, then guide', () => { + const byKey = startRegistration(); + resetS(); + _hidden['editor-tab-preview-modal'] = false; + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(winCalls.hideTabPreview, 1, 'tab-preview lens closed'); + assert.strictEqual(winCalls.toggleGuide.length, 0, 'guide untouched while preview is open'); + assert.ok(e._pd); + + _hidden['editor-tab-preview-modal'] = true; + _hidden['editor-user-guide-modal'] = false; + byKey('Escape').handler(ev('Escape')); + assert.deepStrictEqual(winCalls.toggleGuide, [false], 'guide closed via toggle(false)'); +}); + +test('Escape handler closes the context menu without clearing the selection', () => { + const byKey = startRegistration(); + resetS(); + _hidden['editor-context-menu'] = false; + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(_hidden['editor-context-menu'], true, 'menu closed'); + assert.strictEqual(S.sel.size, 1, 'selection left intact — the menu owned the key'); + assert.ok(e._pd); +}); + +test('Escape handler closes the tool palette', () => { + const byKey = startRegistration(); + resetS(); + editorOpenToolPalette('feedback'); + assert.ok(editorToolPaletteOpen(), 'palette opened for the test'); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.ok(!editorToolPaletteOpen(), 'palette closed'); + assert.ok(e._pd); +}); + +test('Escape handler: suggested-fit ghosts win over a barline multi-selection', () => { + const byKey = startRegistration(); + resetS({ tempoMapMode: true, beats: grid(12, 120), tempoSelMulti: new Set([1, 2]) }); + _suggestCompute(0, onsetsAllBeats(126, 12)); + assert.ok(_suggestActive(), 'precondition: ghosts are showing'); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.ok(!_suggestActive(), 'ghosts dismissed'); + assert.strictEqual(S.tempoSelMulti.size, 2, 'barline selection untouched — ghosts own Escape first'); + assert.ok(e._pd); +}); + +test('Escape handler clears a barline multi-selection when no ghosts show', () => { + const byKey = startRegistration(); + resetS({ tempoMapMode: true, tempoSelMulti: new Set([1, 2]) }); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(S.tempoSelMulti.size, 0, 'barline selection cleared'); + assert.ok(e._pd); +}); + +test('Escape handler clears the note selection as the last rung', () => { + const byKey = startRegistration(); + resetS(); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(S.sel.size, 0, 'note selection cleared'); + assert.ok(e._pd); +}); + +test('Escape handler with nothing to dismiss leaves the key to the Host', () => { + const byKey = startRegistration(); + resetS({ sel: new Set() }); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(e._pd, false, 'unowned Escape is not consumed — back-to-library survives'); +}); + +test('Escape handler bails while a transient modal is open (selection intact)', () => { + const byKey = startRegistration(); + resetS(); + _hidden['editor-command-palette'] = false; + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(S.sel.size, 1, 'must not clear behind the palette'); + assert.strictEqual(e._pd, false, 'the modal owns the key'); +}); + +test('Escape handler ignores modifier chords', () => { + const byKey = startRegistration(); + resetS(); + const e = ev('Escape', { shiftKey: true }); + byKey('Escape').handler(e); + assert.strictEqual(S.sel.size, 1); + assert.strictEqual(e._pd, false); +}); + +test('Escape handler leaves the read-only Tracks overview to the Host', () => { + const byKey = startRegistration(); + resetS({ partsViewMode: true }); + const e = ev('Escape'); + byKey('Escape').handler(e); + assert.strictEqual(S.sel.size, 1, 'parts-view selection untouched, as before #39'); + assert.strictEqual(e._pd, false); +}); + +test('Escape handler yields to the in-app text and choice prompts', () => { + const byKey = startRegistration(); + resetS(); + _hidden['editor-text-prompt'] = false; + const e1 = ev('Escape'); + byKey('Escape').handler(e1); + assert.strictEqual(S.sel.size, 1, 'selection not cleared behind a text prompt'); + assert.strictEqual(e1._pd, false); + + _hidden['editor-text-prompt'] = true; + _hidden['editor-choice-prompt'] = false; + const e2 = ev('Escape'); + byKey('Escape').handler(e2); + assert.strictEqual(S.sel.size, 1, 'selection not cleared behind a choice prompt'); + assert.strictEqual(e2._pd, false); +}); + +test('Space handler accepts the Host token / code form of the event', () => { + const byKey = startRegistration(); + resetS(); + winCalls.togglePlay = 0; + byKey('Space').handler(ev('Space')); + assert.strictEqual(winCalls.togglePlay, 1, 'token-shaped key string'); + byKey('Space').handler(ev('', { code: 'Space' })); + assert.strictEqual(winCalls.togglePlay, 2, 'e.code === Space'); +}); + +// ── No double-handling while the registry is active ────────────────────────── +test('with the registry active, onKeyDown stands down for every migrated key', () => { + startRegistration(); + resetS(); + winCalls.togglePlay = 0; + hostCalls.regionDelete = 0; + execs = 0; + + onKeyDown(ev(' ')); + assert.strictEqual(winCalls.togglePlay, 0, 'Space not double-fired by the fallback listener'); + + const esc = ev('Escape'); + onKeyDown(esc); + assert.strictEqual(S.sel.size, 1, 'Escape not double-fired by the fallback listener'); + assert.strictEqual(esc._pd, false); + + onKeyDown(ev('Delete')); + assert.strictEqual(execs, 0, 'Delete not double-fired by the fallback listener'); + assert.strictEqual(hostCalls.regionDelete, 0); +}); + // ── Registration atomicity ──────────────────────────────────────────────────── test('a partial registration rolls back and keeps the onKeyDown fallback', () => { unregisterEditorShortcuts();