diff --git a/.gitignore b/.gitignore index 29399b76..8ff48373 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ __pycache__/ *.pyc .pytest_cache/ .tmp/ -node_modules/ +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index e24bd4ee..20293dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +- **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 diff --git a/src/draw.js b/src/draw.js index e21c5b9b..c3518ac8 100644 --- a/src/draw.js +++ b/src/draw.js @@ -15,6 +15,7 @@ */ import { ctx } from './canvas.js'; +import { host } from './host.js'; import { LABEL_W, LANE_H, @@ -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(); } } @@ -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 diff --git a/src/host.js b/src/host.js index de70fb1f..ba57223d 100644 --- a/src/host.js +++ b/src/host.js @@ -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). */ diff --git a/src/input.js b/src/input.js index b61b92ce..5eb3c3a7 100644 --- a/src/input.js +++ b/src/input.js @@ -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`); @@ -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 diff --git a/src/main.js b/src/main.js index d4eedf7b..f8383cc8 100644 --- a/src/main.js +++ b/src/main.js @@ -90,8 +90,9 @@ import { } 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, @@ -487,6 +488,7 @@ setHostHooks({ editorSeekToTime: _editorSeekToTime, refreshDrumPadStrip: _drumPadStripRefresh, editorSnapStepSeconds: _editorSnapStepSeconds, + editorEntryPreviewEnabled: _editorEntryPreviewEnabled, effectiveAudioOffset: () => _effectiveAudioOffset(), applyEditorPendingView: (...a) => _applyEditorPendingView(...a), showAddNote: (...a) => showAddNote(...a), @@ -614,6 +616,7 @@ window.editorOpenBendCurve = editorOpenBendCurve; window.editorUngroupStrum = editorUngroupStrum; window.editorSetEditBlip = editorSetEditBlip; window.editorSetMixLevel = editorSetMixLevel; +window.editorToggleEntryPreview = (force) => editorToggleEntryPreview(force); window.editorToggleGuideClap = _editorToggleGuideClap; window.editorToggleLoopAB = _editorToggleLoopAB; window.editorToggleMetronome = _editorToggleMetronome; diff --git a/src/menu-bar.js b/src/menu-bar.js index c37e2543..1164fec4 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -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' }, diff --git a/src/ruler.js b/src/ruler.js index 7056a3c6..2c211306 100644 --- a/src/ruler.js +++ b/src/ruler.js @@ -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(); } @@ -500,7 +504,7 @@ 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 }; scrubTo(x); return true; } @@ -508,7 +512,9 @@ export function rulerOnMouseDown(e, x, y, w) { 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); diff --git a/src/state.js b/src/state.js index 48d57900..e1481f0c 100644 --- a/src/state.js +++ b/src/state.js @@ -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, diff --git a/tests/entry_preview.test.mjs b/tests/entry_preview.test.mjs new file mode 100644 index 00000000..2577f56e --- /dev/null +++ b/tests/entry_preview.test.mjs @@ -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);