From a7fc3ed9bf85a7efbb1a00c62a27749a55900eff Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 13 Jul 2026 23:54:57 -0500 Subject: [PATCH 1/4] feat(editor): note-entry preview cell + Logic-style ruler snap The dashed keyboard-entry caret now previews the note a typed fret will drop, not just the insertion point: it is sized to the current note value (the snap step, so the box is the exact footprint the note will fill), sits on the caret's string lane, and ghosts the fret it will carry. 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 (View > Snap > Note-entry preview) for mouse-first charters who find the cell distracting. Clicking the ruler now snaps the playhead to the nearest beat/subdivision when snap is on (Logic-style), so the caret lands on a real note position; hold Alt for a free, un-snapped scrub. - src/draw.js: _caretCellWidthPure (mirrors note render width) + richer caret block (fill, fret ghost, gated on host.editorEntryPreviewEnabled) - src/input.js: place-at-caret uses sustain=snap step + remembers the fret; _editorEntryPreviewEnabled / editorToggleEntryPreview (localStorage) - src/ruler.js: scrubTo snaps via snapTime; scrub drag carries bypassSnap=Alt - src/host.js, src/main.js: wire editorEntryPreviewEnabled + window toggle - src/menu-bar.js: View > Snap > Note-entry preview - tests/entry_preview.test.mjs: width helper + persisted-toggle coverage Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 17 +++++++ src/draw.js | 35 ++++++++++++-- src/host.js | 2 + src/input.js | 29 +++++++++++- src/main.js | 7 ++- src/menu-bar.js | 1 + src/ruler.js | 8 +++- tests/entry_preview.test.mjs | 89 ++++++++++++++++++++++++++++++++++++ 8 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 tests/entry_preview.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e24bd4ee..953b3e9c 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: **View ▸ 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..b7dbaf60 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,34 @@ 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) { + // 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())) { const cx = timeToX(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'; + ctx.fillText(String(gf), cx + Math.min(cw, MIN_NOTE_W) / 2, cy + ch / 2); ctx.restore(); } } @@ -843,6 +859,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..5bb6033d 100644 --- a/src/input.js +++ b/src/input.js @@ -111,9 +111,14 @@ 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 > 0 ? step : 0, 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 @@ -140,6 +145,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..ece06ac4 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; } diff --git a/tests/entry_preview.test.mjs b/tests/entry_preview.test.mjs new file mode 100644 index 00000000..7249b712 --- /dev/null +++ b/tests/entry_preview.test.mjs @@ -0,0 +1,89 @@ +/* + * 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); } +} + +// ── _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 ─────────────────────────────────── +t('defaults ON when nothing is stored', () => { + _store = {}; + // Re-reading a fresh module cache isn't possible here, but with an empty store + // the getter's cache may already be seeded ON from import — assert the contract + // via an explicit force to a known state first. + editorToggleEntryPreview(true); + assert.strictEqual(_editorEntryPreviewEnabled(), true); +}); + +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); From f034fb5fa18fde3f6d74d889fd599f88d8ada728 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 19:43:27 +0200 Subject: [PATCH 2/4] fix(editor): declare caretFret in state, drop a dead width clamp, real default-on test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the note-entry preview: - src/state.js: caretFret was written ad-hoc onto S by _editorPlaceAtCaret but never declared beside caretString. Declared, so the caret's two fields live together and the default (open string, fret 0) is discoverable. - src/draw.js: the fret ghost centred on Math.min(cw, MIN_NOTE_W), but cw is Math.max(MIN_NOTE_W, ...) by construction — the min could never bite. Dropped to MIN_NOTE_W with the intent (the digit stays put as the cell grows) in the comment. - src/input.js: place-at-caret computed the snap step, then recomputed it for the cursor advance. One step, used for both, so "the note is as long as the caret advances" is visible in the code, not a coincidence of two calls. - tests/entry_preview.test.mjs: "defaults ON when nothing is stored" forced the flag ON and then asserted it was ON — a tautology that never touched the localStorage default (the getter's cache is seeded by import). It now probes a fresh module instance per case, covering both the empty-store default and the stored-'0' read. Co-Authored-By: Claude Opus 4.8 (1M context) --- node_modules | 1 + src/draw.js | 4 +++- src/input.js | 2 +- src/state.js | 3 +++ tests/entry_preview.test.mjs | 26 +++++++++++++++++++------- 5 files changed, 27 insertions(+), 9 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..c5cf027d --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/byron/Repositories/feedback-plugin-editor/node_modules \ No newline at end of file diff --git a/src/draw.js b/src/draw.js index b7dbaf60..8fda62fd 100644 --- a/src/draw.js +++ b/src/draw.js @@ -501,7 +501,9 @@ export function drawNotes(w) { ctx.font = 'bold 13px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(String(gf), cx + Math.min(cw, MIN_NOTE_W) / 2, cy + ch / 2); + // 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(); } } diff --git a/src/input.js b/src/input.js index 5bb6033d..11634ca1 100644 --- a/src/input.js +++ b/src/input.js @@ -124,7 +124,7 @@ function _editorPlaceAtCaret(fret) { // 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()); + _editorSeekToTime((S.cursorTime || 0) + 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`); 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 index 7249b712..2577f56e 100644 --- a/tests/entry_preview.test.mjs +++ b/tests/entry_preview.test.mjs @@ -29,6 +29,10 @@ 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', () => { @@ -57,13 +61,21 @@ t('grows and shrinks monotonically with the snap step (longer note → wider cel }); // ── toggle: persisted view pref, default ON ─────────────────────────────────── -t('defaults ON when nothing is stored', () => { - _store = {}; - // Re-reading a fresh module cache isn't possible here, but with an empty store - // the getter's cache may already be seeded ON from import — assert the contract - // via an explicit force to a known state first. - editorToggleEntryPreview(true); - assert.strictEqual(_editorEntryPreviewEnabled(), true); +// 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', () => { From e29aac3ba22ad310afe980efc4deeeda5a949ed6 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:00:11 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(editor):=20CodeRabbit=20round=20?= =?UTF-8?q?=E2=80=94=20snapped-time=20advance,=20live=20Alt=20scrub,=20unt?= =?UTF-8?q?rack=20node=5Fmodules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drains the four CodeRabbit findings on #256. - input.js: advance the entry caret from the note's SNAPPED time, not the raw cursor. After a free (Alt) scrub — which this same PR introduces — the cursor sits off-grid, so stepping from it left the next note a fraction of a step off instead of flush against the last. Also drop the `step > 0` ternary: _editorSnapStepSeconds() is Math.max(0.001, ...) on every path, never <= 0. - ruler.js: read e.altKey on each scrub move instead of freezing it at mouse-down, matching how the loopedge/barsel drags already re-read e.shiftKey per move. - .gitignore + node_modules: the tracked absolute symlink is the symptom; the cause is that `node_modules/` (trailing slash) only matches directories, so the worktree symlink was never ignored and a `git add -A` swept it in. Drop the slash so the rule covers both, and untrack the blob. - CHANGELOG: the entry-preview toggle lives under Tempo/Grid > Snap, not View. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 +- CHANGELOG.md | 2 +- node_modules | 1 - src/input.js | 7 +++++-- src/ruler.js | 4 +++- 5 files changed, 10 insertions(+), 6 deletions(-) delete mode 120000 node_modules 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 953b3e9c..20293dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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: **View ▸ Snap ▸ Note-entry preview** toggles + 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 / diff --git a/node_modules b/node_modules deleted file mode 120000 index c5cf027d..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/byron/Repositories/feedback-plugin-editor/node_modules \ No newline at end of file diff --git a/src/input.js b/src/input.js index 11634ca1..5eb3c3a7 100644 --- a/src/input.js +++ b/src/input.js @@ -115,7 +115,7 @@ function _editorPlaceAtCaret(fret) { // 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 > 0 ? step : 0, techniques: {} }; + 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 @@ -124,7 +124,10 @@ function _editorPlaceAtCaret(fret) { // 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) + step); // same step the note is long → notes tile + // 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`); diff --git a/src/ruler.js b/src/ruler.js index ece06ac4..2c211306 100644 --- a/src/ruler.js +++ b/src/ruler.js @@ -512,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); From 91b6fa24c7f0763edf5a23228c92d59617311be3 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 20:05:15 +0200 Subject: [PATCH 4/4] fix(editor): the entry-preview cell must sit where the note will land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a CodeRabbit finding — fallout from this PR's own two features meeting. The caret cell drew at the RAW cursor while _editorPlaceAtCaret places at snapTime(cursor). With snap on and the cursor off-grid — after the free (Alt) scrub this same PR adds, or mid-playback — the dashed box promised a position the typed note would not take. The changelog sells this cell as showing "which note lands and how long, not just where"; it has to be right about where. host.snapTime is already wired (main.js), so the cell just snaps like the placement does. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/draw.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/draw.js b/src/draw.js index 8fda62fd..c3518ac8 100644 --- a/src/draw.js +++ b/src/draw.js @@ -481,7 +481,11 @@ export function drawNotes(w) { // view pref (host.editorEntryPreviewEnabled) — off if the box distracts. if (!keysMode && S.sel.size === 0 && (!host.editorEntryPreviewEnabled || host.editorEntryPreviewEnabled())) { - const cx = timeToX(S.cursorTime || 0); + // 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;