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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 40 additions & 5 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -6634,18 +6638,49 @@ 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();
drawNow();
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;
Expand Down
49 changes: 49 additions & 0 deletions tests/follow_toggle.test.js
Original file line number Diff line number Diff line change
@@ -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);
Loading