From 21195664f564daa42a408e546143a465aa0e451c Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Tue, 7 Jul 2026 08:08:28 -0500 Subject: [PATCH] feat(editor): toggleable follow-playhead (Shift+L) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap 2.3 parity cluster. The playback auto-scroll was unconditional; it is now gated on a follow pref (default ON — unchanged behavior) so an author can pin the view on one passage and keep editing while the song plays. Registry command `toggleFollow` with Shift+L in both profiles; the scroll policy math is extracted into @pure:follow-scroll and pinned by tests (the follow-off case fails on main). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu --- CHANGELOG.md | 5 ++++ screen.js | 45 ++++++++++++++++++++++++++++++---- tests/follow_toggle.test.js | 49 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tests/follow_toggle.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7af153..0663529e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,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 edits are undoable.** Click-add, drag-move (time and lane), Delete, and the G/F/K ghost/flam/choke toggles now run through the editor's shared undo history via four new command classes (`AddDrumHitCmd`, diff --git a/screen.js b/screen.js index b6dce800..89a3bfac 100644 --- a/screen.js +++ b/screen.js @@ -3864,6 +3864,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'toggleOnsetStrip', label: 'Toggle onset detection strip', group: 'View', status: 'ready', keys: { feedback: 'Shift+W', eof: 'Shift+W' } }, { id: 'togglePartsView', label: 'Toggle Parts overview', group: 'View', status: 'ready', keys: { feedback: 'Shift+A', eof: 'Shift+A' } }, { id: 'toggleKeyHighlight', label: 'Toggle in-key highlight (piano roll)', 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' } }, @@ -3984,6 +3985,7 @@ function _editorEofCommandForKeyPure(e, mode) { if (shift && key === 'c') return 'toggleMixer'; 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'; @@ -4098,6 +4100,7 @@ function _editorFeedbackCommandForKeyPure(e, mode) { if (shift && key === 'c') return 'toggleMixer'; 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'; @@ -4832,6 +4835,7 @@ function _editorRunEofCommand(cmd) { case 'toggleOnsetStrip': return _editorToggleOnsetStrip(); case 'togglePartsView': return _editorTogglePartsView(); case 'toggleKeyHighlight': return _editorToggleKeyHighlight(); + case 'toggleFollow': return _editorToggleFollow(); case 'showShortcutHelp': return _editorShowShortcutDiscovery('Shortcut help'); case 'openCommandPalette': return _editorShowShortcutDiscovery('Command palette'); case 'importMidi': _editorOpenImportMidi(); return true; @@ -6042,11 +6046,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(); @@ -6054,6 +6062,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);