Paste under the pointer and pull song items out left in music tabs
game-maker / gate (push) Successful in 3m25s
game-maker / suite (push) Successful in 16m33s

A round of fixes to the Pattern and Song tabs: where a paste lands,
what Shift does on a drag, how far the rows zoom, and how an item of
a song is sized at its ends.

- Shift does what Alt does on a drag: off the grid, on the tick. A
  Shift click on a note or an item still adds or unpicks it. A Shift
  press only decides on letting go: no drag toggles, a drag moves.
- Ctrl+V lands under the pointer. The earliest copy goes on the grid
  line before it, the highest note on its row or the topmost item on
  its track, held to the keyboard or the tracks there are. Off the
  grid or left of bar 1 it lands at the start of playing as before.
- The rows of the piano roll zoom to 64 px instead of 28, the tracks
  of a song to 320 px instead of 160.
- Fit in the Pattern tab zooms the rows as well, so the notes fill
  the height, no longer held to the default of 14 px.
- An item of a song can be pulled out by its left end past the start
  of what it plays, which then repeats. The offset goes round to the
  end of a round. A sound not loaded yet still stops at its start.
- Both ends of an item snap to the start of a round of what it plays
  as well as to the grid, whichever is nearer. The end of a round
  between two ticks is cut to the tick before. A start put within
  half a tick of a round goes right on it, so it plays from the very
  beginning.
- An offset past a whole round is not folded back to 0, so a pattern
  that grows later still plays from where the item said.
- A middle-drag pans past the first and the last row, as it pans
  left of bar 1: the highest note or the first track up to half the
  height down, the other end up to half the height up. The room past
  them is dark in the piano roll and empty in the song.

Tests:
- Unit: song-edit 14, piano-roll 19, song 14 tests. New cases for
  the paste spot, pulling out left, the round snap and Fit.
- e2e 64+65 on melektaus-atx: ALL PASSED - 54 checks (+4). New:
  Shift drag and Shift click, Ctrl+V under the pointer in both tabs,
  Fit zooming the rows, pulling a pattern out left and snapping a
  sound to the end of its second round. e2e 64 zooms the rows out
  again after Fit, so the keys it plays later are on screen.
- Gate on melektaus-atx: 251 files, 3912 tests, CHECK EXIT 0.
- Then the pan down the rows: e2e 64 on melektaus-atx ALL PASSED -
  30 checks. Its pan check now also pans the top row down, half the
  grid's height, and a click above it draws nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 19:14:28 +02:00
1 parent a3101c590c
commit 5b96d0b329
11 files changed
+462 -101

No files matched your search

+65 -3
View File
@@ -201,6 +201,22 @@ export async function pattern(t) {
free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720,
JSON.stringify({ free, back }),
);
// Shift does the same; a Shift click that does not move still takes the note out of the picked ones.
await drag(p.x, p.y, to.x, to.y, { modifiers: { shift: true } });
const shiftFree = (await notes()).find((n) => n.id === first);
const shiftPicked = await json('__gm.pattern.picked()');
await press('z', { ctrl: true });
await click(p.x, p.y, 'left', { shift: true });
const unpicked = await json('__gm.pattern.picked()');
await click(p.x, p.y);
check(
'with Shift held it moves off the grid too, and a Shift click still unpicks the note',
shiftFree?.tick % 120 !== 0 &&
Math.abs(shiftFree.tick - 770) <= 6 &&
shiftPicked.includes(first) &&
!unpicked.includes(first),
JSON.stringify({ shiftFree, shiftPicked, unpicked }),
);
// 64e. A band picks both; Ctrl+D puts a copy right behind them, picked; the arrows transpose it.
p = await json('__gm.pattern.gridAt(100, 67)');
@@ -260,6 +276,24 @@ export async function pattern(t) {
startSet === 1920 && pasted.length === 8 && pastedIds.length === 4 && Math.min(...pastedTicks) === 1920,
JSON.stringify({ startSet, count: pasted.length, pastedTicks }),
);
// With the pointer over the grid the paste lands there instead: the earliest note on the grid line
// before it, the highest on its row. Ctrl+Z takes it back.
const copiedHigh = Math.max(...pasted.filter((n) => pastedIds.includes(n.id)).map((n) => n.note));
p = await json('__gm.pattern.gridAt(2930, 70)');
await t.mouse('mouseMoved', p.x, p.y);
await press('v', { ctrl: true });
const atPointer = await notes();
const atPointerIds = await json('__gm.pattern.picked()');
const landed = atPointer.filter((n) => atPointerIds.includes(n.id));
await press('z', { ctrl: true });
check(
'with the pointer over the grid Ctrl+V pastes there: the earliest note at its grid line, the highest on its row',
atPointer.length === 12 &&
Math.min(...landed.map((n) => n.tick)) === 2880 &&
Math.max(...landed.map((n) => n.note)) === 70 &&
(await notes()).length === 8,
JSON.stringify({ copiedHigh, landed: landed.map((n) => [n.tick, n.note]) }),
);
// 64h. A right-click takes a note away; the lane sets the velocity of the one it grabs.
p = await json('__gm.pattern.gridAt(1980, 60)');
@@ -317,13 +351,41 @@ export async function pattern(t) {
const countPanned = (await notes()).length;
await click(zero.x - 20, zero.y);
const countLeft = (await notes()).length;
// Down the same: the highest note comes half the grid's height down at most (the grid is the area
// less the 30 px ruler and the 64 px velocity lane), and a click above it draws nothing.
const gridHeight = area.h - 30 - 64;
for (let i = 0; i < 16; i++) {
const before = (await json('__gm.pattern.view()')).panY;
await drag(zero.x + 40, area.y + 40, zero.x + 40, area.y + area.h - 70, { button: 'middle' });
if ((await json('__gm.pattern.view()')).panY === before) break;
}
const pannedDown = await json('__gm.pattern.view()');
await click(zero.x + 40, area.y + 40);
const countAbove = (await notes()).length;
p = await json('__gm.pattern.fitAt()');
await click(p.x, p.y);
const fitted = await json('__gm.pattern.view()');
check(
'a middle-drag pans bar 1 in from the left edge, half the grid at most, and a click left of it draws nothing',
Math.abs(panned.panX - (area.w - 84) / 2) < 1 && countLeft === countPanned,
JSON.stringify({ panned, area, countPanned, countLeft }),
'a middle-drag pans bar 1 in from the left and the top row down, half the grid at most, and a click outside draws nothing',
Math.abs(panned.panX - (area.w - 84) / 2) < 1 &&
countLeft === countPanned &&
Math.abs(pannedDown.panY - gridHeight / 2) < 1 &&
countAbove === countPanned,
JSON.stringify({ panned, pannedDown, area, countPanned, countLeft, countAbove }),
);
// The notes span few keys: Fit zooms the rows past the old default of 14 px to fill the height.
check(
'Fit zooms the rows too, so the notes fill the height',
fitted.rowHeight > 14,
JSON.stringify(fitted),
);
// The checks below play keys up to 69: the wheel over the keys takes the rows out until they show.
for (let i = 0; i < 8; i++) {
const high = await json('__gm.pattern.keyAt(70)');
const low = await json('__gm.pattern.keyAt(59)');
if (high.y > area.y + 50 && low.y < area.y + area.h - 70) break;
await wheel(low.x, (low.y + high.y) / 2, 200);
}
p = await json('__gm.pattern.loopAt(1920)');
to = await json('__gm.pattern.loopAt(2880)');
await drag(p.x, p.y, to.x, to.y);
+36 -1
View File
@@ -5,7 +5,8 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
* Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds
* three more. A pattern and a
* sound dragged in from the Assets panel land as items where the preview showed them; an item's
* right end lengthens it, its body moves it across tracks, its left end cuts it back, Ctrl+D puts a
* right end lengthens it, its body moves it across tracks, its left end cuts it back or pulls it
* out earlier, an end snaps to the end of a round, Ctrl+V lands under the pointer, Ctrl+D puts a
* copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click
* on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
@@ -231,6 +232,40 @@ export async function song(t) {
JSON.stringify((await model()).items.find((item) => item.id === riff.id)) === JSON.stringify(movedRiff),
);
// 65e-ii. Its left end pulls it out a bar earlier, repeating what it plays; the sound's right end
// snaps to the end of its second round, not to the bar; Ctrl+V lands under the pointer.
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'start')`);
to = await json(`__gm.song.laneAt(${BAR}, 2)`);
await drag(p.x, p.y, to.x, to.y);
await sleep(200);
const pulledOut = (await model()).items.find((item) => item.id === riff.id);
await press('z', { ctrl: true });
const round = seconds * 960;
p = await json(`__gm.song.itemAt(${JSON.stringify(clip.id)}, 'end')`);
to = await json(`__gm.song.laneAt(${Math.round(2.1 * round)}, 0)`);
await drag(p.x, p.y, to.x, to.y);
await sleep(200);
const twoRounds = (await model()).items.find((item) => item.id === clip.id);
await press('z', { ctrl: true });
// The sound is picked from its drag; a click on the pattern here would make 65f's a double-click.
await press('c', { ctrl: true });
p = await json(`__gm.song.laneAt(${6 * BAR + 500}, 1)`);
await mouse('mouseMoved', p.x, p.y);
await press('v', { ctrl: true });
const pastedItem = (await model()).items.find((item) => item.id !== riff.id && item.id !== clip.id);
await press('z', { ctrl: true });
check(
'the left end pulls an item out earlier, the right end snaps to the end of a round, and Ctrl+V lands under the pointer',
pulledOut?.tick === BAR &&
pulledOut.length === 4 * BAR &&
pulledOut.offset === 0 &&
twoRounds?.length === Math.floor(2 * round + 1e-6) &&
pastedItem?.tick === 6 * BAR &&
pastedItem.track === tracks[1] &&
(await model()).items.length === 2,
JSON.stringify({ pulledOut, round, twoRounds, pastedItem }),
);
// 65f. Ctrl+D puts a copy right behind it; a click on the ruler and Ctrl+E split the copy there.
p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`);
await click(p.x, p.y);