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

+69 -19
View File
@@ -87,6 +87,12 @@ DARK_LABEL.fill = 0x30343c;
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel';
/**
* What a press with Shift did to the picked notes: `added` the note, or found it picked – then a
* click that changes nothing `unpick`s it. A drag with Shift moves or sizes off the grid instead.
*/
type ShiftPress = 'added' | 'unpick' | null;
type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| { kind: 'key'; note: number; voice: number }
@@ -109,9 +115,10 @@ type Gesture =
notes: number;
moved: boolean;
pointer: Vec2;
shift: ShiftPress;
}
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
| { kind: 'trim'; anchor: PatternNote; by: number }
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean; shift: ShiftPress }
| { kind: 'trim'; anchor: PatternNote; by: number; shift: ShiftPress }
| { kind: 'erase'; erased: Set<string> }
| { kind: 'velocity'; anchor: PatternNote; value: number };
@@ -157,6 +164,8 @@ export class PianoRoll extends Widget {
private gesture: Gesture | null = null;
/** The part of a note under the pointer while nothing is held. */
private hover: NoteHit | null = null;
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
private pointer: Vec2 | null = null;
/** The notes picked, by id; only this tab knows them. */
private picked = new Set<string>();
/** Where playing starts and a paste lands, in ticks. */
@@ -202,6 +211,11 @@ export class PianoRoll extends Widget {
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointermove', (e: FederatedPointerEvent) => {
const p = this.toLocal(e.global);
this.pointer = { x: p.x, y: p.y };
});
this.on('pointerleave', () => (this.pointer = null));
this.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => {
@@ -311,11 +325,18 @@ export class PianoRoll extends Widget {
return true;
}
/** Lands at the start of playing; false with nothing copied, so Ctrl+V can fall through. */
/**
* Lands under the pointer – the earliest note on the grid line before it, the highest on its row –
* or, with the pointer off the grid, at the start of playing. False with nothing copied, so Ctrl+V
* can fall through.
*/
paste(): boolean {
const asset = this.asset();
if (!asset || this.clipboard.length === 0) return false;
const copies = placedCopies(this.clipboard, this.startTick);
const spot = this.pasteSpot(asset);
const copies = spot
? placedCopies(this.clipboard, spot.tick, spot.note)
: placedCopies(this.clipboard, this.startTick);
this.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes');
this.pick(copies.map((n) => n.id));
return true;
@@ -516,7 +537,10 @@ export class PianoRoll extends Widget {
edge,
};
} else if (area === 'ruler') {
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey) };
this.gesture = {
kind: 'cursor',
tick: this.cursorTick(tick, patternGrid(asset.props), e.altKey || e.shiftKey),
};
} else if (area === 'grid') {
this.pressGrid(asset, e, g);
} else if (area === 'lane') {
@@ -531,25 +555,31 @@ export class PianoRoll extends Widget {
const hit = noteAt(this.view, asset.notes, g);
const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined;
if (!hit || !anchor) {
this.gesture = { kind: 'empty', start: g, current: g, active: false, add: e.shiftKey, free: e.altKey };
this.gesture = {
kind: 'empty',
start: g,
current: g,
active: false,
add: e.shiftKey,
free: e.altKey || e.shiftKey,
};
return;
}
let shift: ShiftPress = null;
if (e.shiftKey) {
if (this.picked.has(hit.id)) this.picked.delete(hit.id);
else this.picked.add(hit.id);
return;
}
if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
shift = this.picked.has(hit.id) ? 'unpick' : 'added';
this.picked.add(hit.id);
} else if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
if (hit.part === 'end') {
this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
this.gesture = { kind: 'resize', anchor, by: 0, moved: false, shift };
return;
}
if (hit.part === 'start') {
this.gesture = { kind: 'trim', anchor, by: 0 };
this.gesture = { kind: 'trim', anchor, by: 0, shift };
return;
}
const grab = xToTick(this.view, g.x) - anchor.tick;
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g };
this.gesture = { kind: 'move', anchor, grab, ticks: 0, notes: 0, moved: false, pointer: g, shift };
this.onPreview(anchor.note, false);
}
@@ -565,7 +595,7 @@ export class PianoRoll extends Widget {
}
const tick = xToTick(this.view, g.x);
const grid = patternGrid(asset.props);
const free = e.altKey;
const free = e.altKey || e.shiftKey;
switch (gesture.kind) {
case 'pan':
this.view = clampRows(
@@ -665,7 +695,10 @@ export class PianoRoll extends Widget {
if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) {
const label = this.picked.size === 1 ? 'Move note' : 'Move notes';
this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label);
} else if (!gesture.moved) this.picked = new Set([gesture.anchor.id]);
} else if (!gesture.moved) {
if (gesture.shift === null) this.picked = new Set([gesture.anchor.id]);
else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
}
break;
case 'resize':
if (gesture.by !== 0) {
@@ -675,7 +708,7 @@ export class PianoRoll extends Widget {
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
);
this.lastLength = gesture.anchor.length + gesture.by;
}
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
break;
case 'trim':
if (gesture.by !== 0) {
@@ -685,7 +718,7 @@ export class PianoRoll extends Widget {
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
);
this.lastLength = gesture.anchor.length - gesture.by;
}
} else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
break;
case 'erase':
if (gesture.erased.size > 0) {
@@ -750,7 +783,19 @@ export class PianoRoll extends Widget {
this.invalidate();
}
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
/** Where a paste goes with the pointer over the grid: the grid line before it and its row; else null. */
private pasteSpot(asset: PatternAsset): { tick: number; note: number } | null {
const p = this.pointer;
if (!p || p.x > this.w || p.y < 0 || this.areaAt(p) !== 'grid') return null;
const g = this.inGrid(p);
const tick = xToTick(this.view, g.x);
const note = noteAtY(this.view, g.y);
// Left of tick 0 nothing lands, as nothing is drawn there.
if (note === null || tick < 0) return null;
return { tick: snapDown(tick, patternGrid(asset.props)), note };
}
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt or Shift on the tick. */
private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
}
@@ -952,6 +997,11 @@ export class PianoRoll extends Widget {
this.gridG.rect(0, 0, zero, gh).fill({ color: 0x000000, alpha: 0.5 });
this.laneG.rect(0, 0, zero, VELOCITY_HEIGHT).fill({ color: 0x000000, alpha: 0.35 });
}
// Above the highest note and below the lowest is there to look at too.
const top = Math.min(gh, noteTop(this.view, HIGHEST_NOTE));
const bottom = Math.max(0, noteTop(this.view, LOWEST_NOTE) + this.view.rowHeight);
if (top > 0) this.gridG.rect(0, 0, gw, top).fill({ color: 0x000000, alpha: 0.5 });
if (bottom < gh) this.gridG.rect(0, bottom, gw, gh - bottom).fill({ color: 0x000000, alpha: 0.5 });
}
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
+15 -4
View File
@@ -5,7 +5,7 @@ import { clamp } from '../../ui';
/**
* What the piano roll does to notes (#333), as plain functions on lists so a test can check them.
* Everything snaps to the grid unless `free` – Alt held –, which puts it on the tick.
* Everything snaps to the grid unless `free` – Alt or Shift held –, which puts it on the tick.
*/
/** The grid line at or before a tick. */
@@ -134,11 +134,22 @@ export function duplicateOffset(
return Math.max(grid, Math.ceil((end - first) / grid) * grid);
}
/** Copies of notes with ids of their own, moved so the earliest starts at `at`. */
export function placedCopies(notes: readonly PatternNote[], at: number): PatternNote[] {
/**
* Copies of notes with ids of their own, moved so the earliest starts at `at` – and, given `top`,
* the highest lands on that note, as far as the keyboard lets them all go.
*/
export function placedCopies(notes: readonly PatternNote[], at: number, top?: number): PatternNote[] {
if (notes.length === 0) return [];
const first = Math.min(...notes.map((n) => n.tick));
return notes.map((n) => ({ ...n, id: newId(), tick: Math.max(0, at) + (n.tick - first) }));
const low = Math.min(...notes.map((n) => n.note));
const high = Math.max(...notes.map((n) => n.note));
const pitch = top === undefined ? 0 : clamp(top - high, LOWEST_NOTE - low, HIGHEST_NOTE - high);
return notes.map((n) => ({
...n,
id: newId(),
tick: Math.max(0, at) + (n.tick - first),
note: n.note + pitch,
}));
}
/**
+6 -7
View File
@@ -2,7 +2,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../../core/instrument/notes';
import type { PatternNote } from '../../core/music/pattern';
import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui';
import { type TimeView, tickToX } from '../music/timeView';
import { type TimeView, clampRowPan, tickToX } from '../music/timeView';
/**
* The piano roll's geometry (#333), apart from Pixi so a test can drive it. Across it is a time
@@ -17,7 +17,7 @@ export interface RollView extends TimeView {
export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
export const MIN_ROW_HEIGHT = 6;
export const MAX_ROW_HEIGHT = 28;
export const MAX_ROW_HEIGHT = 64;
export const DEFAULT_ROW_HEIGHT = 14;
/** How far in from an end of a note a press takes that end rather than the note. */
export const END_GRIP = 6;
@@ -36,10 +36,9 @@ export function noteAtY(view: RollView, y: number): number | null {
return note >= LOWEST_NOTE && note <= HIGHEST_NOTE ? note : null;
}
/** Keeps the rows on screen: no gap above the highest note or below the lowest. */
/** Keeps the rows in reach: half the height past the highest note or the lowest at most. */
export function clampRows(view: RollView, height: number): RollView {
const min = Math.min(0, height - ROWS * view.rowHeight);
return { ...view, panY: clamp(view.panY, min, 0) };
return { ...view, panY: clampRowPan(view.panY, ROWS * view.rowHeight, height) };
}
/** Zooms the rows about `y`, so the note under the pointer stays under it. */
@@ -51,10 +50,10 @@ export function zoomRows(view: RollView, factor: number, y: number): RollView {
/**
* The rows from `low` to `high` in the middle of `height` pixels, a row of room above and below:
* at the default height where they fit, else lower.
* as high as that fills the height, within what the rows can be zoomed to.
*/
export function centreRows(low: number, high: number, height: number): Pick<RollView, 'panY' | 'rowHeight'> {
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, DEFAULT_ROW_HEIGHT);
const rowHeight = clamp(height / (high - low + 3), MIN_ROW_HEIGHT, MAX_ROW_HEIGHT);
const middle = (HIGHEST_NOTE - (low + high) / 2 + 0.5) * rowHeight;
return { rowHeight, panY: height / 2 - middle };
}