Size notes by either end and pan before bar one in music tabs

A round of small fixes to the Pattern and Song tabs: what the ruler
and a pan do in both, and a few things the tabs carried that were in
the way.

- A middle-drag can take bar 1 up to half the grid or lanes in. The
  time before it is drawn dark on the ruler, the grid and the lanes.
  Nothing lands there: a click draws no note, a drop shows no frame.
- A press or drag on the ruler puts the start on the nearest grid
  line, no longer on the one before the pointer.
- A new song has one track instead of four. The "+ Track" row under
  the heads is gone, a head's menu adds tracks above or below. A song
  still keeps at least one, so that menu is always there.
- A note can be sized by its left end too: the start goes to the
  nearest grid line and the end stays put, never before tick 0.
- An end of a note under the pointer lights up and shows a resize
  cursor, as on an item of the Song tab.

Tests:
- Unit: piano-roll, song-edit, song, song-play 52 tests. Two new
  cases for the left end and the pan, the song fixtures add their
  tracks themselves now.
- e2e 64+65 on melektaus-atx: ALL PASSED - 50 checks (+2).
  e2e 65 adds its tracks through the head's menu and clicks the
  ruler three quarters into a bar, which only the nearest line
  passes.
- Gate on melektaus-atx: 251 files, 3909 tests, CHECK EXIT 0.
- A probe shot of the piano roll panned in: the time before bar 1
  dark, the left end of a note lit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 18:40:13 +02:00
1 parent aa097bd076
commit a3101c590c
15 files changed
+260 -89

No files matched your search

+23 -3
View File
@@ -14,12 +14,14 @@ import {
import {
clampShift,
draggedLength,
draggedStart,
drawnNote,
duplicateOffset,
moveDelta,
placedCopies,
resized,
shifted,
trimmed,
withVelocity,
} from '../../src/editor/pattern/rollEdit';
import {
@@ -48,10 +50,12 @@ const VIEW: RollView = { panX: 0, pxPerTick: 0.1, panY: -(127 - 60) * 10, rowHei
const COMMON = { beats: 4, unit: 4 };
describe('time view of the piano roll (#333)', () => {
it('zooms about the pointer and never shows time before 0', () => {
it('zooms about the pointer and pans tick 0 at most half the width in', () => {
const zoomed = zoomTime({ panX: -100, pxPerTick: 0.1 }, 2, 300);
expect(xToTick(zoomed, 300)).toBeCloseTo(4000);
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }).panX).toBe(0);
expect(clampTimePan({ panX: 40, pxPerTick: 0.1 }, 600).panX).toBe(40);
expect(clampTimePan({ panX: 400, pxPerTick: 0.1 }, 600).panX).toBe(300);
expect(clampTimePan({ panX: -50, pxPerTick: 0.1 }, 600).panX).toBe(-50);
expect(fitTime(7680, 768)).toEqual({ panX: 0, pxPerTick: 0.1 });
});
@@ -117,11 +121,12 @@ describe('rows of the piano roll (#333)', () => {
expect(centreRows(0, 127, 200).rowHeight).toBe(6);
});
it('tells a note body from its right end, and takes the one drawn last', () => {
it('tells a note body from its ends, and takes the one drawn last', () => {
const notes = [note('a', 0, 480), note('b', 240, 480)];
expect(noteAt(VIEW, notes, { x: 10, y: 5 })).toEqual({ id: 'a', part: 'body' });
expect(noteAt(VIEW, notes, { x: 30, y: 5 })).toEqual({ id: 'b', part: 'body' });
expect(noteAt(VIEW, notes, { x: 70, y: 5 })).toEqual({ id: 'b', part: 'end' });
expect(noteAt(VIEW, notes, { x: 25, y: 5 })).toEqual({ id: 'b', part: 'start' });
expect(noteAt(VIEW, notes, { x: 10, y: 15 })).toBeNull();
// A note of a tick is still 4 px to the pointer.
expect(noteAt(VIEW, [note('c', 1000, 1)], { x: 102, y: 5 })?.id).toBe('c');
@@ -185,6 +190,21 @@ describe('editing in the piano roll (#333)', () => {
]);
});
it('drags a start to the nearest grid line, its end kept, never up to the end or before 0', () => {
const anchor = note('a', 240, 240);
expect(draggedStart(anchor, 130, 120, false)).toBe(120);
expect(draggedStart(anchor, 470, 120, false)).toBe(360);
expect(draggedStart(anchor, -300, 120, false)).toBe(0);
expect(draggedStart(anchor, 479.6, 120, true)).toBe(479);
const notes = trimmed([anchor, note('b', 60, 60), note('c', 0, 60)], new Set(['a', 'b']), -120);
expect(notes.map((n) => [n.tick, n.length])).toEqual([
[120, 360],
[0, 120],
[0, 60],
]);
expect(trimmed([anchor], new Set(['a']), 999).map((n) => [n.tick, n.length])).toEqual([[479, 1]]);
});
it('puts a duplicate right behind the picked notes, in whole grid steps', () => {
const notes = [note('a', 0, 120), note('b', 240, 60), note('c', 960, 120)];
expect(duplicateOffset(notes, new Set(['a', 'b']), 120)).toBe(360);
+3 -3
View File
@@ -138,9 +138,9 @@ describe('where things are in the Song tab (#334)', () => {
]);
});
it('scrolls no further than the tracks and the row that adds one', () => {
it('scrolls no further than the tracks', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT - 28);
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT);
});
it('tells the parts of a head apart and reads a slider from left to right', () => {
@@ -164,7 +164,7 @@ describe('where things are in the Song tab (#334)', () => {
expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150));
expect(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_TRACK_HEIGHT);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 10 * 2 * TRACK_HEIGHT - 28);
expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 10 * 2 * TRACK_HEIGHT);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5,
pxPerTick: 0.3,