From b1bd27a9980e1c3bb5e47021b1efad340a38357d Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 21 Jul 2026 17:11:14 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(editor):=20the=20EOF=20profile's=20whe?= =?UTF-8?q?el=20adjusts=20sustain=20and=20fret=20=E2=80=94=20the=20mouse?= =?UTF-8?q?=20half=20of=20the=20EOF=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOF's wheel is a note-entry verb, not navigation: the reference keyset binds the scroll wheel to sustain length and Ctrl+scroll to fret. Per the profile policy (EOF-PROFILE-POLICY.md) this is the highest-value faithfulness item — the charter's literal inner loop — and it lands self-contained to the Legacy (EOF) profile. _editorEofWheelActionPure (shortcuts.js) owns the decision: EOF profile only, live selection only, note mode only (tempo map / Tracks overview / drum grid keep their wheel grammars), vertical-dominant only, never with Shift/Alt (pan and the roll's lane-stretch keep their meanings). With nothing selected the wheel falls through to pan — EOF treats a selection-less wheel as a no-op, so faithfulness costs nothing and a bare scroll can never edit. mouse.js dispatches the returned registry command id through a new host.runShortcutCommand hook (input.js's own by-id dispatcher, wired in main.js) — the same implementations as the [ ] and Ctrl+± keys, so undo, status, and clamping are identical, and no mouse.js → input.js import cycle is introduced. The EOF panel rows now advertise the wheel beside those keys. Right-click add/remove and whole-strum click — the reference's other mouse verbs — already ride the profile defaults; tests/eof_wheel.test.mjs pins all of it (the reference's mouse lines with verdicts, every guard on both sides, and the untouched FeedBack displays). Runtime-verified through the real host with a loaded 1256-note song: 11/11 — right-click add/remove, wheel sustain round-trip, Ctrl+wheel fret round-trip (inspector model values, not pixels), Ctrl+wheel no longer zooming in EOF, and the FeedBack profile's wheel untouched. Suites: 306 tests, the only 2 failures (mixer_meter_teardown, song_fit) already fail on main. Lint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0178CGdtptemWd4tUbjkYDhH Signed-off-by: ChrisBeWithYou --- CHANGELOG.md | 12 +++++ src/host.js | 7 +++ src/main.js | 1 + src/mouse.js | 18 +++++++- src/shortcuts.js | 34 ++++++++++++-- tests/eof_wheel.test.mjs | 96 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 tests/eof_wheel.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a4251e15..c6ebeb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 unmarked rows match the reference, and the subtitle counts the buckets. A "Show differences" filter lists only the exceptions. Fidelity is derived from one small divergence map — faithful bindings need no annotation. +- **The Legacy (EOF) profile's wheel is now a note-entry verb, as in EOF + itself.** With notes selected in note mode, the plain wheel lengthens or + shortens their sustain (wheel-up = lengthen) and **Ctrl+wheel** raises or + lowers their fret — the mouse half of the EOF port, beside the existing + right-click add/remove and whole-strum click. The gesture dispatches the + same registry commands as the `[` `]` and `Ctrl+±` keys, so undo, status, + and clamping are identical, and the shortcut panel now lists the wheel + beside those keys in the EOF profile. Scoped hard: EOF profile only, live + selection only, note mode only — the tempo map, Tracks overview, and drum + grid keep their own wheel grammars, Shift/Alt keep pan and the roll's + lane-stretch, and with nothing selected the wheel still pans, so a bare + scroll can never edit. Every other profile is untouched. ### Fixed diff --git a/src/host.js b/src/host.js index b2d057cb..4c44d7a9 100644 --- a/src/host.js +++ b/src/host.js @@ -45,6 +45,13 @@ export const host = { updateArrangementSelector: () => {}, /** Dismiss the canvas context menu. */ hideContextMenu: () => {}, + /** + * Run a registry command by id — the shortcut-panel dispatcher + * (input.js editorRunShortcutCommand). Lets pointer gestures reuse the + * exact keyboard command semantics (undo, status, clamping) without a + * mouse.js → input.js import cycle. Inert default: "not handled". + */ + runShortcutCommand: () => false, /** Snap a time to the active grid (or the nearest onset). Identity default. */ snapTime: (t) => t, /** diff --git a/src/main.js b/src/main.js index 508aef64..98657830 100644 --- a/src/main.js +++ b/src/main.js @@ -503,6 +503,7 @@ setHostHooks({ updateStatus, updateArrangementSelector, hideContextMenu, + runShortcutCommand: editorRunShortcutCommand, snapTime, ensureArr: _historyEnsureArr, editBlipAt: _editBlipAt, diff --git a/src/mouse.js b/src/mouse.js index 652c185a..afaa2ff5 100644 --- a/src/mouse.js +++ b/src/mouse.js @@ -20,7 +20,7 @@ import { _recState } from './midi-record.js'; import { _resizeSustainsForDeltaPure, _resizeTargetIndicesPure, notes } from './notes.js'; import { MINIMAP_GRIP_W, _minimapHitPure, _minimapSongDur, _minimapThumbPure, _rulerZonePure, rulerOnDblClick, rulerOnMouseDown, rulerOnMouseMove, rulerOnMouseUp } from './ruler.js'; import { _laneScrollForThumbPure, applyLaneScrollBounds, laneBarHit, laneBarRect, laneScrollBy, setLaneScrollY } from './lane-scroll.js'; -import { _editorChordGrabsStrumPure, _editorEffectiveChordSelectBehaviorPure, editorChordSelectBehavior, editorShortcutProfile } from './shortcuts.js'; +import { _editorChordGrabsStrumPure, _editorEffectiveChordSelectBehaviorPure, _editorEofWheelActionPure, editorChordSelectBehavior, editorShortcutProfile } from './shortcuts.js'; import { S } from './state.js'; import { _tempoBeatOnDragMove, _tempoMapOnDragEnd, _tempoMapOnDragMove, _tempoMapOnMouseDown, _tempoMarqueeOnEnd, _tempoPoleGrabTolerancePure, _tempoSyncAtX } from './tempo.js'; import { editorCloseToolPalette, editorLeftTool, editorToolPaletteOpen, editorTrackToolMouse } from './tools.js'; @@ -859,6 +859,22 @@ export function onDblClick(e) { export function onWheel(e) { e.preventDefault(); + // Legacy (EOF) profile: the wheel is a note-entry verb when notes are + // selected in note mode — plain wheel = sustain, Ctrl+wheel = fret, as in + // EOF itself. _editorEofWheelActionPure owns the exact scope (profile, + // mode, selection, modifiers); dispatching the registry command id reuses + // the [ ] / Ctrl+± implementations, so undo, status, and clamping match + // the keyboard exactly. Null falls through to the normal wheel grammar. + { + const eofCmd = _editorEofWheelActionPure(e, { + profile: editorShortcutProfile, + tempoMapMode: S.tempoMapMode, + partsViewMode: S.partsViewMode, + drumEditMode: S.drumEditMode, + selCount: S.sel ? S.sel.size : 0, + }); + if (eofCmd) { host.runShortcutCommand(eofCmd); return; } + } // Tracks area: a vertical-dominant wheel scrolls the shared lane stack // (header column + canvas lanes together); a horizontal-dominant swipe // still pans the timeline. diff --git a/src/shortcuts.js b/src/shortcuts.js index 045e20a2..60899686 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -100,8 +100,8 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'nextAnchor', label: 'Jump to next anchor', group: 'Timeline', status: 'ready', keys: { feedback: 'Ctrl+Alt+Right', eof: 'Alt+Page Down' } }, { id: 'gotoBookmarkDigit', label: 'Jump to bookmark 1-9', group: 'Timeline', status: 'ready', keys: { feedback: 'Alt+1-9', eof: 'Numpad 1-9 / Alt+1-9' } }, { id: 'setBookmarkDigit', label: 'Set / clear bookmark 1-9 at cursor', group: 'Timeline', status: 'ready', keys: { feedback: 'Shift+Alt+1-9', eof: 'Ctrl+Numpad 1-9 / Shift+Alt+1-9' } }, - { id: 'shortenSustain', label: 'Shorten selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: '[', eof: '[' } }, - { id: 'lengthenSustain', label: 'Lengthen selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: ']', eof: ']' } }, + { id: 'shortenSustain', label: 'Shorten selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: '[', eof: '[ / Wheel down' } }, + { id: 'lengthenSustain', label: 'Lengthen selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: ']', eof: '] / Wheel up' } }, { id: 'toggleSnap', label: 'Toggle snap on/off', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'G', logical: 'Shift+G', cableton: 'Ctrl+4', eof: '' } }, { id: 'snapDown', label: 'Decrease snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: ',', logical: 'Ctrl+,', cableton: 'Ctrl+2', eof: ',' } }, { id: 'snapUp', label: 'Increase snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: '.', logical: 'Ctrl+.', cableton: 'Ctrl+1', eof: '.' } }, @@ -136,8 +136,8 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'togglePop', label: 'Toggle pop / pluck', group: 'Techniques', status: 'ready', keys: { feedback: 'O', cableton: 'Ctrl+Shift+P', eof: 'Ctrl+Shift+P' } }, { id: 'toggleSlap', label: 'Toggle slap', group: 'Techniques', status: 'ready', keys: { feedback: 'Shift+O', eof: 'Shift+O' } }, { id: 'cyclePickDirection', label: 'Cycle pick direction', group: 'Techniques', status: 'ready', keys: { feedback: 'K', logical: 'Shift+K', eof: 'K' } }, - { id: 'fretUp', label: 'Increase selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl++', eof: 'Ctrl++' } }, - { id: 'fretDown', label: 'Decrease selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl+-', eof: 'Ctrl+-' } }, + { id: 'fretUp', label: 'Increase selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl++', eof: 'Ctrl++ / Ctrl+Wheel up' } }, + { id: 'fretDown', label: 'Decrease selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl+-', eof: 'Ctrl+- / Ctrl+Wheel down' } }, { id: 'setAnchor', label: 'Set anchor at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+F', eof: 'Shift+F' } }, { id: 'selectLike', label: 'Select matching string/fret', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+L', cableton: 'Ctrl+Shift+L', eof: 'Ctrl+L / Shift+L' } }, { id: 'duplicateSelection', label: 'Duplicate selection to next position', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+D', eof: 'Ctrl+D' } }, @@ -372,6 +372,32 @@ export function _editorEofCommandForKeyPure(e, mode) { return null; } +// ── The EOF wheel grammar (the mouse half of the EOF port) ─────────────────── +// In EOF the wheel is a NOTE-ENTRY verb, not navigation: plain wheel adjusts +// the selected notes' sustain, Ctrl+wheel their fret (EOF_hotkeys.txt lines 1 +// and 5; wheel-up = lengthen / fret-up). This decides whether a wheel event is +// that verb; mouse.js dispatches the returned registry command id, so undo, +// status, and clamping stay identical to the [ ] and Ctrl+± keys. +// +// Scoped hard, per EOF-PROFILE-POLICY.md: +// • Legacy (EOF) profile only — every other profile keeps pan/zoom untouched. +// • Note mode only: the tempo map, Tracks overview, and drum grid keep their +// own wheel grammars (no EOF analogue exists for any of them). +// • A live selection only. EOF itself treats a selection-less wheel as a +// no-op; falling through to pan instead keeps navigation alive without +// ever causing a silent wrong edit. +// • Never with Shift/Alt held (pan and the roll's lane-stretch keep their +// meanings), and only on a vertical-dominant gesture. +export function _editorEofWheelActionPure(e, ctx) { + if ((ctx && ctx.profile) !== 'eof') return null; + if (ctx.tempoMapMode || ctx.partsViewMode || ctx.drumEditMode) return null; + if (!ctx.selCount) return null; + if (e.shiftKey || e.altKey) return null; + if (!e.deltaY || Math.abs(e.deltaY) < Math.abs(e.deltaX || 0)) return null; + const up = e.deltaY < 0; + if (e.ctrlKey || e.metaKey) return up ? 'fretUp' : 'fretDown'; + return up ? 'lengthenSustain' : 'shortenSustain'; +} // ── Logical / Cableton — delta tables over the FeedBack resolver ───────────── // Sig (from _editorKeySigPure) → command id. A sig in the table WINS (which is diff --git a/tests/eof_wheel.test.mjs b/tests/eof_wheel.test.mjs new file mode 100644 index 00000000..002f6dba --- /dev/null +++ b/tests/eof_wheel.test.mjs @@ -0,0 +1,96 @@ +/* + * The EOF wheel grammar — the mouse half of the EOF port (EOF-PROFILE-POLICY.md). + * + * EOF_hotkeys.txt mouse lines, and where each lands: + * Mouse scroll wheel = Adjust sustain length → MATCH (this suite) + * Ctrl + scroll wheel = Increment/decrement fret → MATCH (this suite) + * Left click = Select note (whole strum) → MATCH (chord default, pinned below) + * Right click = Add/remove note → MATCH (eofEdit default, pinned below) + * Middle click = Open pro guitar note box → ADAPTED (middle-drag stays pan; + * the N key / double-click reach the same note box) + * + * _editorEofWheelActionPure is the real decision function mouse.js dispatches + * from — driven here directly (never stubbed), with every guard exercised on + * both sides per the repo's stateful-wiring test rules. + * + * Run: node tests/eof_wheel.test.mjs + */ +import assert from 'node:assert'; +import { + _editorDefaultChordSelectBehaviorPure, _editorDefaultRightClickBehaviorPure, + _editorEofWheelActionPure, _editorShortcutRowsPure, +} from '../src/shortcuts.js'; + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const wheel = (dy, m = {}) => ({ + deltaY: dy, deltaX: m.dx || 0, + ctrlKey: !!m.ctrl, metaKey: !!m.meta, shiftKey: !!m.shift, altKey: !!m.alt, +}); +const CTX = { profile: 'eof', tempoMapMode: false, partsViewMode: false, drumEditMode: false, selCount: 2 }; +const act = (e, over = {}) => _editorEofWheelActionPure(e, { ...CTX, ...over }); + +t('plain wheel maps to sustain — up lengthens, down shortens (EOF wheel-up = lengthen)', () => { + assert.strictEqual(act(wheel(-120)), 'lengthenSustain'); + assert.strictEqual(act(wheel(120)), 'shortenSustain'); +}); + +t('Ctrl+wheel maps to fret — up increments, down decrements; Meta counts as Ctrl', () => { + assert.strictEqual(act(wheel(-120, { ctrl: true })), 'fretUp'); + assert.strictEqual(act(wheel(120, { ctrl: true })), 'fretDown'); + assert.strictEqual(act(wheel(-120, { meta: true })), 'fretUp'); +}); + +t('only the EOF profile gets the grammar — every other profile falls through', () => { + for (const profile of ['feedback', 'logical', 'cableton', undefined]) { + assert.strictEqual(act(wheel(-120), { profile }), null, `profile=${profile}`); + assert.strictEqual(act(wheel(-120, { ctrl: true }), { profile }), null, `ctrl profile=${profile}`); + } +}); + +t('note mode only — tempo map, Tracks overview, and the drum grid keep their wheel', () => { + assert.strictEqual(act(wheel(-120), { tempoMapMode: true }), null); + assert.strictEqual(act(wheel(-120), { partsViewMode: true }), null); + assert.strictEqual(act(wheel(-120), { drumEditMode: true }), null); +}); + +t('no selection → null (falls through to pan; a bare wheel never edits)', () => { + assert.strictEqual(act(wheel(-120), { selCount: 0 }), null); + assert.strictEqual(act(wheel(-120, { ctrl: true }), { selCount: 0 }), null); +}); + +t('Shift and Alt keep their existing meanings (pan / roll lane-stretch)', () => { + assert.strictEqual(act(wheel(-120, { shift: true })), null); + assert.strictEqual(act(wheel(-120, { alt: true })), null); +}); + +t('horizontal-dominant swipes and zero deltas fall through', () => { + assert.strictEqual(act(wheel(-10, { dx: 80 })), null, 'horizontal swipe'); + assert.strictEqual(act(wheel(0)), null, 'no vertical delta'); +}); + +t('the reference mouse defaults hold: right-click adds/removes, a click grabs the strum', () => { + // EOF's other two mouse verbs ride the existing profile defaults — pinned + // so a default change breaks this suite, not just runtime behaviour. + assert.strictEqual(_editorDefaultRightClickBehaviorPure('eof'), 'eofEdit'); + assert.strictEqual(_editorDefaultChordSelectBehaviorPure('eof'), 'chord'); + assert.strictEqual(_editorDefaultRightClickBehaviorPure('feedback'), 'context', 'other profiles keep context menus'); +}); + +t('the panel advertises the wheel beside the keys in the EOF profile only', () => { + const eofRows = Object.fromEntries(_editorShortcutRowsPure('eof').map(r => [r.id, r.key])); + assert.strictEqual(eofRows.shortenSustain, '[ / Wheel down'); + assert.strictEqual(eofRows.lengthenSustain, '] / Wheel up'); + assert.strictEqual(eofRows.fretUp, 'Ctrl++ / Ctrl+Wheel up'); + assert.strictEqual(eofRows.fretDown, 'Ctrl+- / Ctrl+Wheel down'); + const fbRows = Object.fromEntries(_editorShortcutRowsPure('feedback').map(r => [r.id, r.key])); + assert.strictEqual(fbRows.shortenSustain, '[', 'FeedBack display untouched'); + assert.strictEqual(fbRows.fretUp, 'Ctrl++', 'FeedBack display untouched'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From 9e5390cb8cbf840fb90a67bd8eb86c1421e16aab Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 21 Jul 2026 21:35:06 -0500 Subject: [PATCH 2/2] Clarify EOF wheel modifiers and fallback --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ebeb36..2d7f6257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 one small divergence map — faithful bindings need no annotation. - **The Legacy (EOF) profile's wheel is now a note-entry verb, as in EOF itself.** With notes selected in note mode, the plain wheel lengthens or - shortens their sustain (wheel-up = lengthen) and **Ctrl+wheel** raises or + shortens their sustain (wheel-up = lengthen) and **Ctrl/Cmd+wheel** raises or lowers their fret — the mouse half of the EOF port, beside the existing right-click add/remove and whole-strum click. The gesture dispatches the same registry commands as the `[` `]` and `Ctrl+±` keys, so undo, status, @@ -36,8 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 beside those keys in the EOF profile. Scoped hard: EOF profile only, live selection only, note mode only — the tempo map, Tracks overview, and drum grid keep their own wheel grammars, Shift/Alt keep pan and the roll's - lane-stretch, and with nothing selected the wheel still pans, so a bare - scroll can never edit. Every other profile is untouched. + lane-stretch, and with nothing selected the wheel falls through to its + existing behavior, so a bare scroll can never edit. Every other profile is untouched. ### Fixed