diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bb0af9..27b0b7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 silences the edit cue); time-only moves and marquee selects stay silent, group edits rate-limit to one cue, and the blip never fires when the audio context isn't running. Tests: `tests/audio_mixer.test.js`. +- **Follow-playhead is toggleable (Shift+L).** The playback auto-scroll + (view jumps once the cursor crosses 80% of the window) was unconditional; + it is now a registry command in both shortcut profiles, default ON, so an + author can pin the view on one passage and edit while the song plays on. + Editor pref only. Tests: `tests/follow_toggle.test.js`. - **Drum velocity is authorable.** Velocity always rendered (brightness/ height) and imported from MIDI, but authoring wrote a hardcoded `v:100` with no way to change it. Now: **Alt+vertical-drag** on selected hits diff --git a/screen.js b/screen.js index 7ab6ebb4..4966ffd9 100644 --- a/screen.js +++ b/screen.js @@ -4193,6 +4193,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'cycleViewMode', label: 'Cycle part view (String / Piano roll)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'showTabPreview', label: 'Preview part as tab (read-only, saved pack)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'toggleDrumDensity', label: 'Toggle drum row density (Full / Compact)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } }, + { id: 'toggleFollow', label: 'Toggle follow playhead', group: 'View', status: 'ready', keys: { feedback: 'Shift+L', eof: 'Shift+L' } }, { id: 'showShortcutHelp', label: 'Show shortcut help', group: 'View', status: 'ready', keys: { feedback: '?', eof: '?' } }, { id: 'openCommandPalette', label: 'Open command palette', group: 'View', status: 'ready', keys: { feedback: 'Ctrl+K', eof: 'Ctrl+K' } }, { id: 'importMidi', label: 'Import MIDI / keys', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F6' } }, @@ -4314,6 +4315,7 @@ function _editorEofCommandForKeyPure(e, mode) { if (alt && key === 'b') return 'toggleLoopAB'; if (shift && key === 'w') return 'toggleOnsetStrip'; if (shift && key === 'a') return 'togglePartsView'; + if (shift && key === 'l') return 'toggleFollow'; if (shift && key === '?') return 'showShortcutHelp'; if (ctrl && key === 'k') return 'openCommandPalette'; if (sig === 'F6') return 'importMidi'; @@ -4429,6 +4431,7 @@ function _editorFeedbackCommandForKeyPure(e, mode) { if (alt && key === 'b') return 'toggleLoopAB'; if (shift && key === 'w') return 'toggleOnsetStrip'; if (shift && key === 'a') return 'togglePartsView'; + if (shift && key === 'l') return 'toggleFollow'; if (shift && key === '?') return 'showShortcutHelp'; if (ctrl && key === 'k') return 'openCommandPalette'; if (sig === 'PageUp') return 'prevBeat'; @@ -5356,6 +5359,7 @@ function _editorRunEofCommand(cmd) { case 'toggleOnsetStrip': return _editorToggleOnsetStrip(); case 'togglePartsView': return _editorTogglePartsView(); case 'toggleKeyHighlight': return _editorToggleKeyHighlight(); + case 'toggleFollow': return _editorToggleFollow(); case 'toggleDrumDensity': return _editorToggleDrumDensity(); case 'showTabPreview': return _editorShowTabPreview(); case 'cycleViewMode': return _editorCycleViewMode(); @@ -6634,11 +6638,15 @@ function playbackTick() { return; // stopPlayback() already cancelled rafId; don't re-schedule. } - // Auto-scroll to follow cursor - const cx = timeToX(S.cursorTime); - const w = canvas ? canvas.width / DPR : 800; - if (cx > w * 0.8) { - S.scrollX = _editorClampScrollX(S.cursorTime - (w * 0.3) / S.zoom); + // Auto-scroll to follow the playhead — unless follow is toggled off + // (Shift+L), which lets an author inspect/edit one spot while the + // song plays on. + { + const cx = timeToX(S.cursorTime); + const w = canvas ? canvas.width / DPR : 800; + const target = _followScrollTargetPure( + S.cursorTime, cx, w, S.zoom, editorFollowEnabled()); + if (target !== null) S.scrollX = _editorClampScrollX(target); } updateTimeDisplay(); @@ -6646,6 +6654,33 @@ function playbackTick() { rafId = requestAnimationFrame(playbackTick); } +/* @pure:follow-scroll:start */ +// Follow-playhead scroll policy: once the cursor crosses 80% of the view, +// jump the window so the cursor sits at 30% — but only when follow is on. +// Returns the UNCLAMPED scrollX target, or null for "don't move". +function _followScrollTargetPure(cursorTime, cursorX, viewW, zoom, followOn) { + if (!followOn) return null; + if (!(cursorX > viewW * 0.8)) return null; + return cursorTime - (viewW * 0.3) / zoom; +} +/* @pure:follow-scroll:end */ + +function editorFollowEnabled() { + // Default ON — follow is today's behavior; the pref only records an + // explicit opt-out. + try { return localStorage.getItem('editorFollow') !== '0'; } + catch (_) { return true; } +} + +function _editorToggleFollow() { + const next = !editorFollowEnabled(); + try { localStorage.setItem('editorFollow', next ? '1' : '0'); } catch (_) {} + setStatus(next + ? 'Follow on — the view tracks the playhead during playback (Shift+L)' + : 'Follow off — the view stays put while the song plays (Shift+L)'); + return true; +} + function updatePlayIcon() { const icon = document.getElementById('editor-play-icon'); if (!icon) return; diff --git a/tests/follow_toggle.test.js b/tests/follow_toggle.test.js new file mode 100644 index 00000000..43b462d2 --- /dev/null +++ b/tests/follow_toggle.test.js @@ -0,0 +1,49 @@ +'use strict'; +/* + * Tests for the follow-playhead toggle (@pure:follow-scroll block): the + * playback auto-scroll is now gated on the follow pref (Shift+L), so an + * author can inspect one spot while the song plays on. The policy math + * (80% trigger, 30% landing) is unchanged from the shipped behavior — + * pinned here; the follow-off null return fails on main, where the + * scroll is unconditional. + * + * Run: node tests/follow_toggle.test.js + */ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); +const m = src.match(/\/\* @pure:follow-scroll:start \*\/[\s\S]*?\/\* @pure:follow-scroll:end \*\//); +if (!m) { + console.error('FAIL: @pure:follow-scroll block not found in screen.js'); + process.exit(1); +} +const { _followScrollTargetPure } = new Function( + '"use strict";' + m[0] + '\nreturn { _followScrollTargetPure };' +)(); + +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); } +} + +t('follow on: cursor past 80% of the view jumps it to the 30% mark', () => { + // 800px view at zoom 100 px/s, cursor at 10 s drawn at x=700 (> 640). + const target = _followScrollTargetPure(10, 700, 800, 100, true); + assert.ok(Math.abs(target - (10 - 240 / 100)) < 1e-9, 'cursor lands at 30% of the view'); +}); + +t('follow on: cursor inside the window leaves scroll alone', () => { + assert.strictEqual(_followScrollTargetPure(10, 300, 800, 100, true), null); + assert.strictEqual(_followScrollTargetPure(10, 640, 800, 100, true), null, 'exactly at 80% does not trigger'); +}); + +t('follow OFF: never scrolls, even past the trigger point', () => { + assert.strictEqual(_followScrollTargetPure(10, 700, 800, 100, false), null); + assert.strictEqual(_followScrollTargetPure(999, 100000, 800, 100, false), null); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail) process.exit(1);