From dfdce1b8af7f53cd4c5dfa082d6178f1655a9b57 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 12 Jul 2026 14:37:19 -0500 Subject: [PATCH 1/2] feat(editor): group drag for a barline multi-selection (tempo PR 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grabbing any pole in a Tempo Map multi-selection now drags the whole group by one offset, instead of only the single focused barline. - _tempoGroupDragClampPure: the group's Δt is clamped by the tightest headroom of any selected pole against its nearest UNSELECTED (fixed) downbeat ± MIN_MEASURE, so the group moves rigidly and can never cross a fixed neighbour or reorder the grid. Song start (0) / passed `duration` bound a pole with no fixed neighbour on that side. - _tempoApplyGroupDragPure: shifts every selected downbeat by the clamped Δt, then re-spaces each measure's interior proportionally between its (possibly moved) downbeats — a span between two selected poles rigid-shifts for free, an edge span re-spaces against its fixed outside pole, and a leading pickup / trailing tail rigid-shifts only when its bounding downbeat moved. Pure (never mutates the input). - Locked poles are dropped from the moving group: they stay put and act as fixed anchors; a status notes how many stayed. Locks defend hand-verified times, so excluding them is least surprising. - Wiring mirrors the single-pole drag: mousedown starts a 'tempo-group' drag when a multi-selection (2+) is grabbed by one of its poles; _tempoMapOnDragMove rebuilds from the original grid each move (no compounding); the shared _tempoMapOnDragEnd finalizes it as one undoable TempoMapCmd (equal-count invariant) — notes ride the grid. The selection is index-preserving, so it survives the drag. tests/tempo_group_drag.test.mjs (12 cases): clamp math both directions, fixed-pole-between-two-selected binding, rigid interior + edge re-space, pickup/tail shift, locked-pole exclusion, all-locked no-op, purity, and an exec -> undo -> redo round-trip that also checks a note rides. Fails on main (the pures don't exist). 116 JS green, lint 0-err. Verified live on the testbed: Ctrl+A -> drag a pole -> "Moved 105 barlines together." Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 11 ++ src/mouse.js | 6 +- src/tempo.js | 134 +++++++++++++++++++++++-- tests/tempo_group_drag.test.mjs | 171 ++++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 tests/tempo_group_drag.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c3dc8a..0448b9b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 lock / modulate / suggest) is unchanged; the multi-selection is separate and is dropped on any grid-topology change. +- **Drag a whole barline selection at once** in Tempo Map mode. Grab any pole in + a multi-selection and the entire group slides together by one offset — the + spans *between* selected barlines shift rigidly (their tempo is preserved), + while the spans at the selection's edges re-space against the fixed barline + just outside it, exactly like a single pole drag. The group stops as soon as + any member would collide with a fixed neighbour, so it can never reorder the + grid, and notes ride the move. **Locked barlines are excluded** — they stay + put and act as fixed anchors the group re-spaces around (the status says how + many stayed), since a lock's whole job is to defend a hand-verified time. One + undoable step (Move N barlines together). + - **Pitched GM guide voices** (DAW workspace 1.2/1.5). The guide can now play the charted notes as a real General-MIDI instrument instead of the clap: Transport ▸ Guide voice ▸ Instrument (GM), with a per-part-kind instrument diff --git a/src/mouse.js b/src/mouse.js index 00704ae4..bbc035fb 100644 --- a/src/mouse.js +++ b/src/mouse.js @@ -349,7 +349,9 @@ function _onMouseMoveBody(e, x, y, L) { } // Tempo-map drag: re-space the two measures around the dragged pole. - if (S.drag.type === 'tempo-sync') { + // A group drag (a multi-selection grabbed by one of its poles) rides the + // same handler — it dispatches on S.drag.type internally. + if (S.drag.type === 'tempo-sync' || S.drag.type === 'tempo-group') { _tempoMapOnDragMove(x); return; } @@ -472,7 +474,7 @@ export function onMouseUp(e) { return; } - if (S.drag.type === 'tempo-sync' || S.drag.type === 'tempo-beat') { + if (S.drag.type === 'tempo-sync' || S.drag.type === 'tempo-beat' || S.drag.type === 'tempo-group') { _tempoMapOnDragEnd(); return; } diff --git a/src/tempo.js b/src/tempo.js index a66d32e6..4fb95118 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -865,6 +865,35 @@ export function _tempoMapOnMouseDown(e, x, y) { setStatus(`${S.tempoSelMulti.size} barline${S.tempoSelMulti.size === 1 ? '' : 's'} selected.`); return; } + // Group drag (PR 5b): grabbing a pole that belongs to a multi-selection + // of 2+ downbeats drags the whole selection rigidly. Locked poles are + // excluded — they defend re-fits, so excluding them is least surprising — + // and the status says how many stayed put. The selection is KEPT (a + // TempoMapCmd is index-preserving), so the group survives the drag. + if (S.tempoSelMulti && S.tempoSelMulti.size >= 2 && S.tempoSelMulti.has(hit)) { + const all = [...S.tempoSelMulti]; + const movable = all.filter(i => S.beats[i] && S.beats[i].measure > 0 && !S.beats[i].locked); + if (movable.length) { + if (hit !== S.tempoSel) _tapTempo = null; + S.tempoSel = hit; + S.drag = { + type: 'tempo-group', + selIdxs: movable, + startX: x, + startTime: xToTime(x), + origBeats: S.beats.map(b => ({ ...b })), + moved: false, + }; + const lockedOut = all.length - movable.length; + if (lockedOut > 0) { + setStatus(`Dragging ${movable.length} barline${movable.length === 1 ? '' : 's'} — ` + + `${lockedOut} locked ${lockedOut === 1 ? 'stays' : 'stay'} put.`); + } + host.draw(); + return; + } + // Every selected pole is locked — fall through to a single focus drag. + } if (hit !== S.tempoSel) _tapTempo = null; // selection moved — drop stale tap run S.tempoSel = hit; if (S.tempoSelMulti) S.tempoSelMulti.clear(); // plain pole click = single focus @@ -1622,6 +1651,81 @@ export function _tempoApplyDrag(beats, d, newT) { } } +/* @pure:tempo-group-drag:start */ +// Group drag (PR 5b) — move a multi-selection of downbeats rigidly by one Δt. +// The clamp keeps the WHOLE group together: it is the tightest headroom of any +// selected pole against its nearest UNSELECTED downbeat (± minGap), so no pole +// can cross a fixed neighbour. A selected pole with no unselected neighbour on +// a side is bounded by the song start (0) / `duration`. Locked and non-selected +// downbeats are treated as fixed, so they constrain (and defend) the move. +// Returns the clamped Δt actually applicable for `deltaT`. +export function _tempoGroupDragClampPure(beats, selIdxs, deltaT, minGap = MIN_MEASURE, duration = 0) { + if (!Array.isArray(beats) || !Array.isArray(selIdxs) || !selIdxs.length) return 0; + const sel = new Set(selIdxs); + let headRight = Infinity, headLeft = Infinity; + for (const i of sel) { + if (!beats[i] || beats[i].measure <= 0) continue; + // Nearest UNSELECTED downbeat to the right — the fixed pole this one + // must stay `minGap` behind; none ⇒ the song end (duration). + let rt = -1; + for (let j = i + 1; j < beats.length; j++) { + if (beats[j].measure > 0 && !sel.has(j)) { rt = j; break; } + } + const hiBound = rt >= 0 + ? beats[rt].time - minGap + : Math.max(beats[i].time, duration || beats[beats.length - 1].time); + headRight = Math.min(headRight, hiBound - beats[i].time); + // Nearest UNSELECTED downbeat to the left; none ⇒ the song start (0). + let lf = -1; + for (let j = i - 1; j >= 0; j--) { + if (beats[j].measure > 0 && !sel.has(j)) { lf = j; break; } + } + const loBound = lf >= 0 ? beats[lf].time + minGap : 0; + headLeft = Math.min(headLeft, beats[i].time - loBound); + } + if (!Number.isFinite(headRight)) headRight = 0; + if (!Number.isFinite(headLeft)) headLeft = 0; + return deltaT >= 0 + ? Math.min(deltaT, Math.max(0, headRight)) + : Math.max(deltaT, -Math.max(0, headLeft)); +} + +// Apply a group drag to a COPY of `beats` (never mutates the input). Every +// selected+unselectable-excluded downbeat shifts by the clamped Δt; each +// measure span then re-spaces its interior proportionally between its +// (possibly moved) downbeats — so a span between two selected poles rigid- +// shifts for free, and a span at the selection's edge re-spaces against its +// fixed outside pole. A leading pickup / trailing tail rigid-shifts only when +// its bounding downbeat moved. Locked poles are dropped from the group (they +// stay put and act as fixed neighbours). Equal length in/out — a TempoMapCmd. +export function _tempoApplyGroupDragPure(beats, selIdxs, deltaT, minGap = MIN_MEASURE, duration = 0) { + const out = (beats || []).map(b => ({ ...b })); + if (!Array.isArray(beats) || !Array.isArray(selIdxs) || !selIdxs.length) return out; + const sel = new Set(selIdxs.filter(i => beats[i] && beats[i].measure > 0 && !beats[i].locked)); + if (!sel.size) return out; + const delta = _tempoGroupDragClampPure(beats, [...sel], deltaT, minGap, duration); + for (const i of sel) out[i].time = beats[i].time + delta; + const dbs = []; + for (let i = 0; i < beats.length; i++) if (beats[i].measure > 0) dbs.push(i); + if (!dbs.length) return out; + // Leading pickup: rigid-shift iff the first downbeat moved. + if (sel.has(dbs[0])) for (let i = 0; i < dbs[0]; i++) out[i].time = beats[i].time + delta; + // Interior spans: proportional re-space between the (new) bounding times. + for (let s = 0; s + 1 < dbs.length; s++) { + const a = dbs[s], b = dbs[s + 1]; + const oldSpan = beats[b].time - beats[a].time; + for (let k = a + 1; k < b; k++) { + const frac = oldSpan > 0 ? (beats[k].time - beats[a].time) / oldSpan : (k - a) / (b - a); + out[k].time = out[a].time + frac * (out[b].time - out[a].time); + } + } + // Trailing tail: rigid-shift iff the last downbeat moved. + const last = dbs[dbs.length - 1]; + if (sel.has(last)) for (let i = last + 1; i < beats.length; i++) out[i].time = beats[i].time + delta; + return out; +} +/* @pure:tempo-group-drag:end */ + // Metric modulation prompt + apply for the selected barline. New tempo // = current × ratio, from the selected measure to the next tempo change // (the uniform-run boundary — hand-authored downstream tempo is a natural @@ -1975,7 +2079,19 @@ export function _tapTempoHandleKey(e) { export function _tempoMapOnDragMove(x) { const dg = S.drag; - if (!dg || dg.type !== 'tempo-sync') return; + if (!dg) return; + // Group drag (PR 5b): rigid Δt over the whole selection, rebuilt from the + // original grid each move (no compounding), clamped so the group can't + // cross a fixed neighbour. Finalized by the shared _tempoMapOnDragEnd. + if (dg.type === 'tempo-group') { + if (!dg.moved && Math.abs(x - dg.startX) < 3) return; + dg.moved = true; + const deltaT = xToTime(x) - dg.startTime; + S.beats = _tempoApplyGroupDragPure(dg.origBeats, dg.selIdxs, deltaT, MIN_MEASURE, S.duration || 0); + host.draw(); + return; + } + if (dg.type !== 'tempo-sync') return; if (!dg.moved && Math.abs(x - dg.startX) < 3) return; dg.moved = true; const d = dg.beatIdx; @@ -1998,10 +2114,11 @@ export function _tempoMapOnDragMove(x) { export function _tempoMapOnDragEnd() { const dg = S.drag; S.drag = null; - // Finalizes both drag kinds — a moved pole ('tempo-sync') and a moved - // individual beat ('tempo-beat') — identically: same revert-then-exec, - // same equal-count invariant, one undoable TempoMapCmd. - if (!dg || (dg.type !== 'tempo-sync' && dg.type !== 'tempo-beat')) return; + // Finalizes every time-only tempo drag — a moved pole ('tempo-sync'), a + // moved individual beat ('tempo-beat'), and a moved multi-selection + // ('tempo-group') — identically: same revert-then-exec, same equal-count + // invariant, one undoable TempoMapCmd. + if (!dg || (dg.type !== 'tempo-sync' && dg.type !== 'tempo-beat' && dg.type !== 'tempo-group')) return; if (!dg.moved) { host.draw(); return; } // a click-select, not a drag const newBeats = S.beats.map(b => ({ ...b })); S.beats = dg.origBeats; // revert — TempoMapCmd.exec re-applies it @@ -2015,7 +2132,12 @@ export function _tempoMapOnDragEnd() { host.draw(); return; } - S.history.exec(new TempoMapCmd(dg.origBeats, newBeats, 'drag')); + const isGroup = dg.type === 'tempo-group'; + S.history.exec(new TempoMapCmd(dg.origBeats, newBeats, isGroup ? 'group-drag' : 'drag')); + if (isGroup) { + const n = dg.selIdxs ? dg.selIdxs.length : 0; + setStatus(`Moved ${n} barline${n === 1 ? '' : 's'} together — notes ride the grid.`); + } host.draw(); } diff --git a/tests/tempo_group_drag.test.mjs b/tests/tempo_group_drag.test.mjs new file mode 100644 index 00000000..78dbdc83 --- /dev/null +++ b/tests/tempo_group_drag.test.mjs @@ -0,0 +1,171 @@ +/* + * Group drag (PR 5b) — moving a multi-selection of barlines together. + * + * Covers the two pures (_tempoGroupDragClampPure / _tempoApplyGroupDragPure): + * the whole-group clamp against the tightest fixed neighbour, rigid interior + * spans between two selected poles, edge spans re-spacing against a fixed + * outside pole, leading-pickup / trailing-tail rigid shift, locked-pole + * exclusion (locks stay put and act as fixed neighbours), input purity, and a + * full exec → undo → redo round-trip through TempoMapCmd (notes ride the grid). + * + * Run: node tests/tempo_group_drag.test.mjs + */ +import assert from 'node:assert'; +import { S } from '../src/state.js'; +import { EditHistory } from '../src/history.js'; +import { + TempoMapCmd, MIN_MEASURE, + _tempoApplyGroupDragPure, _tempoGroupDragClampPure, +} from '../src/tempo.js'; +import { seedState, trackHooks } from './_history_env.mjs'; + +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); } +} +const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps; + +// Downbeats at indices 0/4/8/12 (measures 1..4), 1 s beats, 4 s bars. +function grid() { + const b = []; + for (let m = 0; m < 4; m++) { + b.push({ time: m * 4, measure: m + 1 }); + for (let k = 1; k < 4; k++) b.push({ time: m * 4 + k, measure: -1 }); + } + return b; // length 16; downbeats at 0,4,8,12 +} + +// ── clamp math ─────────────────────────────────────────────────────── +t('clamp: an in-range Δt passes through untouched', () => { + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 8], 0.5, MIN_MEASURE, 16), 0.5)); + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 8], -0.5, MIN_MEASURE, 16), -0.5)); +}); + +t('clamp: the whole group stops at the tightest fixed neighbour (both sides)', () => { + // sel {4,8}: nearest fixed pole right of 8 is 12 (hi = 11.95); the binding + // pole is 8, headroom 11.95 - 8 = 3.95. Left: fixed pole 0 (lo = 0.05), + // binding pole 4, headroom 4 - 0.05 = 3.95. + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 8], 10, MIN_MEASURE, 16), 3.95), 'right clamp'); + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 8], -10, MIN_MEASURE, 16), -3.95), 'left clamp'); +}); + +t('clamp: the first downbeat can\'t cross the song start; the last rides to duration', () => { + assert.ok(near(_tempoGroupDragClampPure(grid(), [0], -5, MIN_MEASURE, 16), 0), 'first pole pinned at 0 leftward'); + assert.ok(near(_tempoGroupDragClampPure(grid(), [12], 5, MIN_MEASURE, 16), 4), 'last pole rides to duration 16'); +}); + +t('clamp: a fixed (unselected) pole between two selected ones binds both directions', () => { + // sel {4,12}, pole 8 unselected & fixed: pole 4 can go right only to 8-.05 + // (3.95), pole 12 can go left only to 8+.05 (3.95). + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 12], 10, MIN_MEASURE, 16), 3.95), 'pole 4 stops at 8-.05'); + assert.ok(near(_tempoGroupDragClampPure(grid(), [4, 12], -10, MIN_MEASURE, 16), -3.95), 'pole 12 left head = 12-(8+.05)'); +}); + +// ── apply: rigid interior + edge re-space ──────────────────────────── +t('apply: a span between two selected poles rigid-shifts; edge spans re-space', () => { + const orig = grid(); + const out = _tempoApplyGroupDragPure(orig, [4, 8], 1, MIN_MEASURE, 16); + // Selected downbeats moved by +1. + assert.ok(near(out[4].time, 5) && near(out[8].time, 9), 'poles 4,8 → 5,9'); + // Interior of the 4→8 span (both endpoints selected): rigid +1. + assert.ok(near(out[5].time, 6) && near(out[6].time, 7) && near(out[7].time, 8), 'interior rigid-shifts'); + // Edge span 0(fixed)→4(now 5): interior re-spaces across [0,5]. + assert.ok(near(out[1].time, 1.25) && near(out[2].time, 2.5) && near(out[3].time, 3.75), 'left edge re-spaced to fixed pole 0'); + // Edge span 8(now 9)→12(fixed): interior re-spaces across [9,12]. + assert.ok(near(out[9].time, 9.75) && near(out[10].time, 10.5) && near(out[11].time, 11.25), 'right edge re-spaced to fixed pole 12'); + // The two fixed poles never move. + assert.ok(near(out[0].time, 0) && near(out[12].time, 12), 'fixed poles held'); +}); + +t('apply: order stays strictly monotonic when slammed to the clamp edge', () => { + const orig = grid(); + const out = _tempoApplyGroupDragPure(orig, [4, 8], 100, MIN_MEASURE, 16); // clamps to +3.95 + for (let i = 1; i < out.length; i++) { + assert.ok(out[i].time > out[i - 1].time, `beats[${i}] ${out[i].time} > ${out[i - 1].time}`); + } +}); + +t('apply: leading pickup and trailing tail rigid-shift with their end downbeat', () => { + // Pickup before bar 1, and a trailing sub-beat past the last downbeat. + const orig = [ + { time: 0.0, measure: -1 }, // pickup + { time: 0.5, measure: -1 }, // pickup + { time: 1.0, measure: 1 }, + { time: 2.0, measure: -1 }, + { time: 3.0, measure: 2 }, + { time: 4.0, measure: -1 }, // trailing tail + ]; + // Move both downbeats (the whole grid) right by +0.5. + const out = _tempoApplyGroupDragPure(orig, [2, 4], 0.5, MIN_MEASURE, 10); + assert.ok(near(out[0].time, 0.5) && near(out[1].time, 1.0), 'pickup rigid-shifts with bar 1'); + assert.ok(near(out[2].time, 1.5) && near(out[4].time, 3.5), 'downbeats moved'); + assert.ok(near(out[5].time, 4.5), 'trailing tail rigid-shifts with the last downbeat'); +}); + +// ── locks: excluded from the group, act as fixed neighbours ────────── +t('apply: a locked pole in the selection stays put and pins its neighbours', () => { + const orig = grid(); + orig[8].locked = true; + // Pass both — the locked 8 must be dropped from the moving group. + const out = _tempoApplyGroupDragPure(orig, [4, 8], 1, MIN_MEASURE, 16); + assert.ok(near(out[8].time, 8), 'locked pole never moves'); + assert.strictEqual(out[8].locked, true, 'lock flag preserved'); + assert.ok(near(out[4].time, 5), 'the unlocked pole moves'); + // The 4→8 span now re-spaces against the FIXED locked pole 8 (edge behaviour). + assert.ok(near(out[5].time, 5.75) && near(out[6].time, 6.5) && near(out[7].time, 7.25), 'interior re-spaces to the locked pole'); +}); + +t('apply: an all-locked selection is a no-op', () => { + const orig = grid(); + orig[4].locked = true; orig[8].locked = true; + const out = _tempoApplyGroupDragPure(orig, [4, 8], 1, MIN_MEASURE, 16); + assert.deepStrictEqual(out.map(b => b.time), orig.map(b => b.time), 'nothing moved'); +}); + +// ── purity ─────────────────────────────────────────────────────────── +t('apply: the input grid is never mutated', () => { + const orig = grid(); + const snapshot = orig.map(b => b.time); + _tempoApplyGroupDragPure(orig, [4, 8], 2, MIN_MEASURE, 16); + assert.deepStrictEqual(orig.map(b => b.time), snapshot, 'orig untouched'); +}); + +t('apply: empty / invalid selection returns an untouched copy (not the same ref)', () => { + const orig = grid(); + const out = _tempoApplyGroupDragPure(orig, [], 1, MIN_MEASURE, 16); + assert.notStrictEqual(out, orig); + assert.deepStrictEqual(out.map(b => b.time), orig.map(b => b.time)); +}); + +// ── round-trip through TempoMapCmd (notes ride the grid) ───────────── +const mkArr = () => ({ + name: 'G', notes: [{ string: 0, time: 6, sustain: 0 }], + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], +}); + +t('exec → undo → redo round-trips beats and notes exactly', () => { + trackHooks(); + seedState({ arrangements: [mkArr()], currentArr: 0, sessionId: 's1', beats: grid(), sections: [], duration: 16, history: new EditHistory() }); + const orig = S.beats.map(b => ({ ...b })); + const before = orig.map(b => b.time); + const noteBefore = S.arrangements[0].notes[0].time; // 6, inside the 4→8 span + + const moved = _tempoApplyGroupDragPure(orig, [4, 8], 1, MIN_MEASURE, 16); + S.history.exec(new TempoMapCmd(orig, moved, 'group-drag')); + assert.ok(near(S.beats[4].time, 5) && near(S.beats[8].time, 9), 'exec moved the poles'); + // The note sat at t=6 (beat 6, mid-span); the span rigid-shifted +1, so it rides to 7. + assert.ok(near(S.arrangements[0].notes[0].time, noteBefore + 1), 'note rode the grid (+1s)'); + assert.strictEqual(S.history.undo.length, 1, 'exactly one undo entry'); + + S.history.doUndo(); + assert.deepStrictEqual(S.beats.map(b => b.time), before, 'undo restores every beat time'); + assert.ok(near(S.arrangements[0].notes[0].time, noteBefore), 'undo restores the note'); + + S.history.doRedo(); + assert.deepStrictEqual(S.beats.map(b => b.time), moved.map(b => b.time), 'redo re-applies the move'); + assert.ok(near(S.arrangements[0].notes[0].time, noteBefore + 1), 'redo rides the note again'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From 2011db9e7145972fb7d971b1afe1147388d179d5 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 13 Jul 2026 00:59:38 +0200 Subject: [PATCH 2/2] Prevent locked group drag fallthrough --- src/tempo.js | 8 ++++++-- tests/tempo_group_drag.test.mjs | 26 ++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/tempo.js b/src/tempo.js index 4fb95118..ef808f82 100644 --- a/src/tempo.js +++ b/src/tempo.js @@ -892,7 +892,12 @@ export function _tempoMapOnMouseDown(e, x, y) { host.draw(); return; } - // Every selected pole is locked — fall through to a single focus drag. + // Every selected pole is locked: keep the group selected and do not + // fall through to a single-pole drag, which would move a protected + // anchor despite the group no-op rule. + setStatus('Selected barlines are locked — unlock one to move it.'); + host.draw(); + return; } if (hit !== S.tempoSel) _tapTempo = null; // selection moved — drop stale tap run S.tempoSel = hit; @@ -2357,4 +2362,3 @@ export class TempoMapCmd { host.updateLoopIn3DBtn(); } } - diff --git a/tests/tempo_group_drag.test.mjs b/tests/tempo_group_drag.test.mjs index 78dbdc83..f754e090 100644 --- a/tests/tempo_group_drag.test.mjs +++ b/tests/tempo_group_drag.test.mjs @@ -11,13 +11,15 @@ * Run: node tests/tempo_group_drag.test.mjs */ import assert from 'node:assert'; +import { setCanvas } from '../src/canvas.js'; +import { TIMELINE_TOP, WAVEFORM_H, timeToX } from '../src/geometry.js'; import { S } from '../src/state.js'; import { EditHistory } from '../src/history.js'; import { TempoMapCmd, MIN_MEASURE, - _tempoApplyGroupDragPure, _tempoGroupDragClampPure, + _tempoApplyGroupDragPure, _tempoGroupDragClampPure, _tempoMapOnMouseDown, } from '../src/tempo.js'; -import { seedState, trackHooks } from './_history_env.mjs'; +import { lastStatus, seedState, trackHooks } from './_history_env.mjs'; let pass = 0, fail = 0; function t(name, fn) { @@ -123,6 +125,26 @@ t('apply: an all-locked selection is a no-op', () => { assert.deepStrictEqual(out.map(b => b.time), orig.map(b => b.time), 'nothing moved'); }); +t('mouse: grabbing an all-locked multi-selection does not fall through to single-pole drag', () => { + const calls = trackHooks(); + const beats = grid(); + beats[4].locked = true; beats[8].locked = true; + seedState({ + arrangements: [], currentArr: 0, beats, sections: [], duration: 16, + history: new EditHistory(), tempoMapMode: true, tempoSel: 4, + tempoSelMulti: new Set([4, 8]), zoom: 100, scrollX: 0, + }); + setCanvas({ height: 600, width: 900, getContext: () => ({}) }); + + _tempoMapOnMouseDown({ shiftKey: false }, timeToX(beats[4].time), TIMELINE_TOP + WAVEFORM_H + 5); + + assert.strictEqual(S.drag, null, 'no single-pole drag is armed'); + assert.deepStrictEqual([...S.tempoSelMulti].sort((a, b) => a - b), [4, 8], 'group selection is kept'); + assert.strictEqual(S.tempoSel, 4, 'focus stays on the clicked locked barline'); + assert.match(lastStatus(), /locked/i); + assert.ok(calls.draw > 0, 'redraws the unchanged selection'); +}); + // ── purity ─────────────────────────────────────────────────────────── t('apply: the input grid is never mutated', () => { const orig = grid();