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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ __pycache__/
*.pyc
.pytest_cache/
.tmp/
node_modules/
node_modules
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The note-entry caret now previews the note you're about to type.** In String
view with nothing selected, the dashed cell that marks the entry point earns
its note shape: it's sized to the current note value (the snap step, so the box
is exactly the footprint a typed note will fill), sits on the caret's string
lane, and ghosts the fret it will carry — the caret shows *which* note lands
and *how long*, not just *where*. Typed notes are placed at that same length so
consecutive entries tile the grid instead of stacking as zero-length notes. The
preview is a persisted view pref: **Tempo/Grid ▸ Snap ▸ Note-entry preview** toggles
it off (and back on) for mouse-first charters who find the cell distracting.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Clicking the ruler snaps the playhead to the grid, Logic-style.** A scrub
click on the timeline ruler now lands the playhead on the nearest beat /
subdivision when snap is on (so the entry caret sits on a real note position),
instead of seeking to the raw pixel time. Hold **Alt** while clicking for a
free, un-snapped scrub.

### Fixed

- **Inspector technique edits are undoable now.** Toggling a technique flag
Expand Down
43 changes: 37 additions & 6 deletions src/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { ctx } from './canvas.js';
import { host } from './host.js';
import {
LABEL_W,
LANE_H,
Expand Down Expand Up @@ -473,19 +474,40 @@ export function drawNotes(w) {
}
}

// Keyboard-entry caret: in String view with nothing selected, show where a
// typed fret will land (caret string × playhead) so entry has a visible
// target. A dashed cyan cell; ↑/↓ move it, 0-9 place a note there.
if (!keysMode && S.sel.size === 0) {
const cx = timeToX(S.cursorTime || 0);
// Keyboard-entry caret: in String view with nothing selected, preview the note
// a typed fret will drop — its STRING (the lane the cell sits on), its LENGTH
// (the box is the note value = the snap step, so it earns its note shape), and
// the FRET it'll carry (a ghosted digit). ↑/↓ move the string, 0-9 place. A
// view pref (host.editorEntryPreviewEnabled) — off if the box distracts.
if (!keysMode && S.sel.size === 0
&& (!host.editorEntryPreviewEnabled || host.editorEntryPreviewEnabled())) {
// Draw on the grid the typed note will actually LAND on: _editorPlaceAtCaret
// places at snapTime(cursor), so after a free (Alt) scrub — or mid-playback —
// the raw cursor sits off-grid and a box drawn there would promise a position
// the note won't take. The cell must not lie about WHERE.
const cx = timeToX(host.snapTime ? host.snapTime(S.cursorTime || 0) : (S.cursorTime || 0));
const cy = strToY(S.caretString || 0) + NOTE_PAD;
const ch = LANE_H - NOTE_PAD * 2;
const step = host.editorSnapStepSeconds ? host.editorSnapStepSeconds() : 0;
const cw = _caretCellWidthPure(step, S.zoom, MIN_NOTE_W);
ctx.save();
// Faint fill: the box is the note's FOOTPRINT (its length), not just an edge.
ctx.fillStyle = '#38bdf822';
ctx.fillRect(cx, cy, cw, ch);
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 2]);
ctx.strokeRect(cx, cy, MIN_NOTE_W, ch);
ctx.strokeRect(cx, cy, cw, ch);
ctx.setLineDash([]);
// Ghost the fret it will carry (last placed), so you see WHICH note lands.
const gf = Math.max(0, Math.min(24, Number(S.caretFret) || 0));
ctx.fillStyle = '#7dd3fc';
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Centred in the cell's FIRST MIN_NOTE_W (the cell is never narrower than
// that), so the digit stays put as the box grows with the note value.
ctx.fillText(String(gf), cx + MIN_NOTE_W / 2, cy + ch / 2);
ctx.restore();
}
}
Expand Down Expand Up @@ -843,6 +865,15 @@ function _drawPianoNote(n, selected, hl, midi, fretted, linted) {
}
}

// The note-entry caret's width in px: the note VALUE (snap step, seconds) at the
// current zoom, floored to a visible minimum so the cell always shows even at
// tiny steps / low zoom. A step of 0 (no grid) → the minimum. Pure.
export function _caretCellWidthPure(stepSec, zoom, minW) {
const mw = Number(minW) || 0;
const w = (Number(stepSec) > 0 && Number(zoom) > 0) ? stepSec * zoom : 0;
return Math.max(mw, w);
}

export function drawCursor(w, h) {
// While playing, paint the playhead at the OUTPUT-latency-compensated time
// (S.cursorDrawTime) so the line sits on the audio actually leaving the
Expand Down
2 changes: 2 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export const host = {
editorSeekToTime: () => {},
/** The current snap step in seconds. */
editorSnapStepSeconds: () => 0,
/** Whether the note-entry preview cell should be drawn (a view pref). */
editorEntryPreviewEnabled: () => true,

// ── Rendering and scroll, for src/audio.js ────────────────────────
/** Force an immediate synchronous repaint (draw() is rAF-coalesced). */
Expand Down
34 changes: 32 additions & 2 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,23 @@ function _editorPlaceAtCaret(fret) {
if (!nStr) { setStatus('Select notes first'); return false; }
const string = Math.max(0, Math.min(nStr - 1, Number(S.caretString) || 0));
const time = snapTime(S.cursorTime || 0);
const note = { time, string, fret: Math.max(0, Math.min(24, Number(fret) || 0)), sustain: 0, techniques: {} };
// Length = the note value (the snap step), so a typed note fills the preview
// cell and consecutive notes tile the grid instead of stacking zero-length.
const step = _editorSnapStepSeconds();
const f = Math.max(0, Math.min(24, Number(fret) || 0));
const note = { time, string, fret: f, sustain: step, techniques: {} };
const cmd = new AddNoteCmd(note);
S.history.exec(cmd);
S.caretFret = f; // remember for the preview's fret ghost
_tourNoteAction('placeNote');
_editBlipAt();
// Entry flow: leave NO selection (so the next digit places again) and advance
// the caret one snap step for rapid sequential entry.
S.sel.clear();
_editorSeekToTime((S.cursorTime || 0) + _editorSnapStepSeconds());
// Advance from the note's SNAPPED time, not the raw cursor: after a free
// (Alt) scrub the cursor sits off-grid, and stepping from it would leave the
// next note a fraction of a step away from this one instead of flush against it.
_editorSeekToTime(time + step); // same step the note is long → notes tile
host.draw();
host.updateStatus();
setStatus(`Placed fret ${note.fret} on string ${string + 1} — type to keep placing, or click a note to edit`);
Expand All @@ -140,6 +148,28 @@ function _editorMoveCaretString(dir) {
return true;
}

// Note-entry preview toggle (default ON): the dashed caret cell that previews a
// typed note's string, length, and fret. A view pref — off if the box distracts
// (e.g. a mouse-only charter). Cached like the other view flags.
let _entryPreviewOn = null;
export function _editorEntryPreviewEnabled() {
if (_entryPreviewOn === null) {
try { _entryPreviewOn = localStorage.getItem('editorEntryPreview') !== '0'; }
catch (_) { _entryPreviewOn = true; }
}
return _entryPreviewOn;
}
export function editorToggleEntryPreview(force) {
const next = typeof force === 'boolean' ? force : !_editorEntryPreviewEnabled();
_entryPreviewOn = next;
try { localStorage.setItem('editorEntryPreview', next ? '1' : '0'); } catch (_) { /* private mode */ }
if (host && typeof host.draw === 'function') host.draw();
setStatus(next
? 'Note-entry preview on — the dashed cell shows where a typed note lands (string · length · fret)'
: 'Note-entry preview off');
return next;
}

function _editorSetSelectedFret(fret) {
const idxs = _editorCurrentNoteIndices();
if (!idxs.length) return _editorPlaceAtCaret(fret); // no selection → keyboard entry
Expand Down
7 changes: 5 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@
} from './tab-preview.js';
import { editorExportGp5 } from './gp5-export.js';
import {
_editorCurrentNoteIndices, _editorSeekToTime, _editorSnapStepSeconds,
editorRunShortcutCommand, editorToggleShortcutPanel, onContextMenu, onKeyDown
_editorCurrentNoteIndices, _editorEntryPreviewEnabled, _editorSeekToTime,
_editorSnapStepSeconds, editorRunShortcutCommand, editorToggleEntryPreview,
editorToggleShortcutPanel, onContextMenu, onKeyDown
} from './input.js';
import {
editorAddString, editorHideStringsModal, editorRemoveString,
Expand Down Expand Up @@ -487,6 +488,7 @@
editorSeekToTime: _editorSeekToTime,
refreshDrumPadStrip: _drumPadStripRefresh,
editorSnapStepSeconds: _editorSnapStepSeconds,
editorEntryPreviewEnabled: _editorEntryPreviewEnabled,
effectiveAudioOffset: () => _effectiveAudioOffset(),
applyEditorPendingView: (...a) => _applyEditorPendingView(...a),
showAddNote: (...a) => showAddNote(...a),
Expand Down Expand Up @@ -614,6 +616,7 @@
window.editorUngroupStrum = editorUngroupStrum;
window.editorSetEditBlip = editorSetEditBlip;
window.editorSetMixLevel = editorSetMixLevel;
window.editorToggleEntryPreview = (force) => editorToggleEntryPreview(force);
window.editorToggleGuideClap = _editorToggleGuideClap;
window.editorToggleLoopAB = _editorToggleLoopAB;
window.editorToggleMetronome = _editorToggleMetronome;
Expand Down Expand Up @@ -1706,7 +1709,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 1712 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
1 change: 1 addition & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'toggleSnapMode' },
{ cmd: 'customGridSnap' },
{ cmd: 'toggleGridDisplay' },
{ label: 'Note-entry preview', fn: 'editorToggleEntryPreview' },
] },
{ title: 'Help', items: [
{ label: 'User Guide', fn: 'editorToggleUserGuide' },
Expand Down
12 changes: 9 additions & 3 deletions src/ruler.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,11 @@ export function drawRuler(w) {
// ── Interaction (routed from mouse.js; drags ride S.drag) ───────────

function scrubTo(x) {
S.cursorTime = Math.max(0, xToTime(x));
// Logic-style: clicking the ruler snaps the playhead to the grid (beat /
// subdivision) when snap is on, so the caret lands on a real note position.
// Hold Alt (S.drag.bypassSnap) for a free, un-snapped scrub.
const raw = Math.max(0, xToTime(x));
S.cursorTime = (S.drag && S.drag.bypassSnap) ? raw : snapTime(raw);
host.draw();
}

Expand Down Expand Up @@ -500,15 +504,17 @@ export function rulerOnMouseDown(e, x, y, w) {
// Scrub: seek immediately and keep tracking while the button is down.
const resume = S.playing;
if (resume) stopPlayback();
S.drag = { type: 'scrub', resume };
S.drag = { type: 'scrub', resume, bypassSnap: e.altKey };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
scrubTo(x);
return true;
}

export function rulerOnMouseMove(e, x, w) {
if (!S.drag) return false;
if (S.drag.type === 'minimap') { minimapPan(x, w); return true; }
if (S.drag.type === 'scrub') { scrubTo(x); return true; }
// Alt is live per move (like Shift on the loop drags): press/release it
// mid-scrub and snapping follows, instead of freezing at the mouse-down state.
if (S.drag.type === 'scrub') { S.drag.bypassSnap = e.altKey; scrubTo(x); return true; }
if (S.drag.type === 'loopedge') {
if (!S.barSel) return true;
const mode = _loopLiveMode(e.shiftKey);
Expand Down
3 changes: 3 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ export const S = {
// note at (caretString, snapped cursorTime). Drawn as a caret cell in that
// state so it reads as an entry position.
caretString: 0,
// The fret the caret cell ghosts (the last one placed) — what a typed digit
// will carry until you type a different one.
caretFret: 0,

// Playback
playing: false,
Expand Down
101 changes: 101 additions & 0 deletions tests/entry_preview.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Note-entry preview tests (gap-audit follow-up): the dashed caret cell now
* previews a typed note's LENGTH (sized to the snap step) and is a persisted,
* toggleable view pref. Covers the pure width helper (src/draw.js) and the
* localStorage-backed toggle (src/input.js).
*
* Run: node --test tests/entry_preview.test.mjs
*/
import assert from 'node:assert';

// Minimal browser surface the modules touch at import / call time.
let _store = {};
globalThis.localStorage = {
getItem: (k) => (k in _store ? _store[k] : null),
setItem: (k, v) => { _store[k] = String(v); },
removeItem: (k) => { delete _store[k]; },
};
globalThis.document = globalThis.document || {
getElementById: () => null, querySelector: () => null,
addEventListener: () => {}, createElement: () => ({ style: {}, classList: { add() {}, remove() {} } }),
};
globalThis.window = globalThis.window || globalThis;

const { _caretCellWidthPure } = await import('../src/draw.js');
const { _editorEntryPreviewEnabled, editorToggleEntryPreview } = await import('../src/input.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); }
}
async function ta(name, fn) {
try { await fn(); pass++; console.log(' ok ' + name); }
catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
}

// ── _caretCellWidthPure: the cell earns its note SHAPE ────────────────────────
t('cell width is the note value (step × zoom) when that beats the minimum', () => {
// 0.5 s step at 200 px/s = 100 px, well over the 8 px minimum.
assert.strictEqual(_caretCellWidthPure(0.5, 200, 8), 100);
});

t('floors to the minimum so the cell always shows at tiny steps / low zoom', () => {
// 1/64-note-ish step at low zoom → sub-pixel; must not vanish.
assert.strictEqual(_caretCellWidthPure(0.01, 100, 8), 8); // 1px < 8
assert.strictEqual(_caretCellWidthPure(0, 200, 8), 8); // no grid → minimum
});

t('a zero/negative zoom or garbage input degrades to the minimum, never NaN', () => {
assert.strictEqual(_caretCellWidthPure(0.5, 0, 8), 8);
assert.strictEqual(_caretCellWidthPure(0.5, -200, 8), 8);
assert.strictEqual(_caretCellWidthPure(NaN, 200, 8), 8);
assert.strictEqual(_caretCellWidthPure(0.5, 200, undefined), 100); // minW absent → 0 floor
});

t('grows and shrinks monotonically with the snap step (longer note → wider cell)', () => {
const eighth = _caretCellWidthPure(0.25, 200, 8);
const quarter = _caretCellWidthPure(0.5, 200, 8);
const half = _caretCellWidthPure(1.0, 200, 8);
assert.ok(eighth < quarter && quarter < half, `${eighth} < ${quarter} < ${half}`);
});

// ── toggle: persisted view pref, default ON ───────────────────────────────────
// The getter caches on first read, so the localStorage-backed DEFAULT can only be
// exercised on a module instance that has not read it yet. A distinct import
// specifier gives us a fresh one (input.js has no import-time side effects).
async function freshEnabled(store) {
_store = store;
const m = await import(`../src/input.js?probe=${Math.random()}`);
return m._editorEntryPreviewEnabled();
}

await ta('defaults ON when nothing is stored', async () => {
assert.strictEqual(await freshEnabled({}), true);
});

await ta('reads a stored OFF back as off', async () => {
assert.strictEqual(await freshEnabled({ editorEntryPreview: '0' }), false);
});

t('toggling flips the flag and persists it to localStorage', () => {
editorToggleEntryPreview(true);
assert.strictEqual(_editorEntryPreviewEnabled(), true);
const off = editorToggleEntryPreview();
assert.strictEqual(off, false);
assert.strictEqual(_editorEntryPreviewEnabled(), false);
assert.strictEqual(_store.editorEntryPreview, '0');
const on = editorToggleEntryPreview();
assert.strictEqual(on, true);
assert.strictEqual(_store.editorEntryPreview, '1');
});

t('an explicit force sets the state directly (idempotent)', () => {
assert.strictEqual(editorToggleEntryPreview(false), false);
assert.strictEqual(editorToggleEntryPreview(false), false);
assert.strictEqual(_editorEntryPreviewEnabled(), false);
assert.strictEqual(editorToggleEntryPreview(true), true);
});

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
Loading