Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/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,
and clamping are identical, and the shortcut panel now lists the wheel
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 falls through to its
existing behavior, so a bare scroll can never edit. Every other profile is untouched.

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
1 change: 1 addition & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@
updateStatus,
updateArrangementSelector,
hideContextMenu,
runShortcutCommand: editorRunShortcutCommand,
snapTime,
ensureArr: _historyEnsureArr,
editBlipAt: _editBlipAt,
Expand Down Expand Up @@ -2031,7 +2032,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 2035 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
18 changes: 17 additions & 1 deletion src/mouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 30 additions & 4 deletions src/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '.' } },
Expand Down Expand Up @@ -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' } },
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions tests/eof_wheel.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading