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

+13
View File
@@ -49,6 +49,19 @@ export function clampTimePan<V extends TimeView>(view: V, width: number): V {
return view.panX > most ? { ...view, panX: most } : view; return view.panX > most ? { ...view, panX: most } : view;
} }
/** How far past the first or the last row (a note, a track) a pan can go, as a share of the area's height. */
export const ROW_LEAD = 0.5;
/**
* Down the area as across it: the first row can be panned up to half the height down from the top
* edge and the last up to half the height up from the bottom, to look at, with nothing to place there.
* Rows that fit with room to spare are not pushed up off the top.
*/
export function clampRowPan(panY: number, content: number, height: number): number {
const lead = Math.max(0, height * ROW_LEAD);
return clamp(panY, Math.min(0, height - lead - content), lead);
}
/** `length` ticks across `width` pixels, from tick 0. */ /** `length` ticks across `width` pixels, from tick 0. */
export function fitTime(length: number, width: number): TimeView { export function fitTime(length: number, width: number): TimeView {
return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) }; return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) };
+69 -19
View File
@@ -87,6 +87,12 @@ DARK_LABEL.fill = 0x30343c;
type Area = 'corner' | 'ruler' | 'keys' | 'grid' | 'lane' | 'laneLabel'; 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 = type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number } | { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| { kind: 'key'; note: number; voice: number } | { kind: 'key'; note: number; voice: number }
@@ -109,9 +115,10 @@ type Gesture =
notes: number; notes: number;
moved: boolean; moved: boolean;
pointer: Vec2; pointer: Vec2;
shift: ShiftPress;
} }
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean } | { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean; shift: ShiftPress }
| { kind: 'trim'; anchor: PatternNote; by: number } | { kind: 'trim'; anchor: PatternNote; by: number; shift: ShiftPress }
| { kind: 'erase'; erased: Set<string> } | { kind: 'erase'; erased: Set<string> }
| { kind: 'velocity'; anchor: PatternNote; value: number }; | { kind: 'velocity'; anchor: PatternNote; value: number };
@@ -157,6 +164,8 @@ export class PianoRoll extends Widget {
private gesture: Gesture | null = null; private gesture: Gesture | null = null;
/** The part of a note under the pointer while nothing is held. */ /** The part of a note under the pointer while nothing is held. */
private hover: NoteHit | null = null; 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. */ /** The notes picked, by id; only this tab knows them. */
private picked = new Set<string>(); private picked = new Set<string>();
/** Where playing starts and a paste lands, in ticks. */ /** 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('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(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.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => { state.on('project', () => {
@@ -311,11 +325,18 @@ export class PianoRoll extends Widget {
return true; 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 { paste(): boolean {
const asset = this.asset(); const asset = this.asset();
if (!asset || this.clipboard.length === 0) return false; 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.commit(asset, [...asset.notes, ...copies], copies.length === 1 ? 'Paste note' : 'Paste notes');
this.pick(copies.map((n) => n.id)); this.pick(copies.map((n) => n.id));
return true; return true;
@@ -516,7 +537,10 @@ export class PianoRoll extends Widget {
edge, edge,
}; };
} else if (area === 'ruler') { } 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') { } else if (area === 'grid') {
this.pressGrid(asset, e, g); this.pressGrid(asset, e, g);
} else if (area === 'lane') { } else if (area === 'lane') {
@@ -531,25 +555,31 @@ export class PianoRoll extends Widget {
const hit = noteAt(this.view, asset.notes, g); const hit = noteAt(this.view, asset.notes, g);
const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined; const anchor = hit ? asset.notes.find((n) => n.id === hit.id) : undefined;
if (!hit || !anchor) { 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; return;
} }
let shift: ShiftPress = null;
if (e.shiftKey) { if (e.shiftKey) {
if (this.picked.has(hit.id)) this.picked.delete(hit.id); shift = this.picked.has(hit.id) ? 'unpick' : 'added';
else this.picked.add(hit.id); this.picked.add(hit.id);
return; } else if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
}
if (!this.picked.has(hit.id)) this.picked = new Set([hit.id]);
if (hit.part === 'end') { if (hit.part === 'end') {
this.gesture = { kind: 'resize', anchor, by: 0, moved: false }; this.gesture = { kind: 'resize', anchor, by: 0, moved: false, shift };
return; return;
} }
if (hit.part === 'start') { if (hit.part === 'start') {
this.gesture = { kind: 'trim', anchor, by: 0 }; this.gesture = { kind: 'trim', anchor, by: 0, shift };
return; return;
} }
const grab = xToTick(this.view, g.x) - anchor.tick; 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); this.onPreview(anchor.note, false);
} }
@@ -565,7 +595,7 @@ export class PianoRoll extends Widget {
} }
const tick = xToTick(this.view, g.x); const tick = xToTick(this.view, g.x);
const grid = patternGrid(asset.props); const grid = patternGrid(asset.props);
const free = e.altKey; const free = e.altKey || e.shiftKey;
switch (gesture.kind) { switch (gesture.kind) {
case 'pan': case 'pan':
this.view = clampRows( this.view = clampRows(
@@ -665,7 +695,10 @@ export class PianoRoll extends Widget {
if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) { if (gesture.moved && (gesture.ticks !== 0 || gesture.notes !== 0)) {
const label = this.picked.size === 1 ? 'Move note' : 'Move notes'; const label = this.picked.size === 1 ? 'Move note' : 'Move notes';
this.commit(asset, shifted(asset.notes, this.picked, gesture.ticks, gesture.notes), label); 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; break;
case 'resize': case 'resize':
if (gesture.by !== 0) { if (gesture.by !== 0) {
@@ -675,7 +708,7 @@ export class PianoRoll extends Widget {
this.picked.size === 1 ? 'Resize note' : 'Resize notes', this.picked.size === 1 ? 'Resize note' : 'Resize notes',
); );
this.lastLength = gesture.anchor.length + gesture.by; this.lastLength = gesture.anchor.length + gesture.by;
} } else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
break; break;
case 'trim': case 'trim':
if (gesture.by !== 0) { if (gesture.by !== 0) {
@@ -685,7 +718,7 @@ export class PianoRoll extends Widget {
this.picked.size === 1 ? 'Resize note' : 'Resize notes', this.picked.size === 1 ? 'Resize note' : 'Resize notes',
); );
this.lastLength = gesture.anchor.length - gesture.by; this.lastLength = gesture.anchor.length - gesture.by;
} } else if (gesture.shift === 'unpick') this.picked.delete(gesture.anchor.id);
break; break;
case 'erase': case 'erase':
if (gesture.erased.size > 0) { if (gesture.erased.size > 0) {
@@ -750,7 +783,19 @@ export class PianoRoll extends Widget {
this.invalidate(); 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 { private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid)); 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.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 }); 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 { 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. * 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. */ /** 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); 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 []; if (notes.length === 0) return [];
const first = Math.min(...notes.map((n) => n.tick)); 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 { PatternNote } from '../../core/music/pattern';
import type { Vec2 } from '../../core/model/Properties'; import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui'; 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 * 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 ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
export const MIN_ROW_HEIGHT = 6; export const MIN_ROW_HEIGHT = 6;
export const MAX_ROW_HEIGHT = 28; export const MAX_ROW_HEIGHT = 64;
export const DEFAULT_ROW_HEIGHT = 14; export const DEFAULT_ROW_HEIGHT = 14;
/** How far in from an end of a note a press takes that end rather than the note. */ /** How far in from an end of a note a press takes that end rather than the note. */
export const END_GRIP = 6; 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; 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 { export function clampRows(view: RollView, height: number): RollView {
const min = Math.min(0, height - ROWS * view.rowHeight); return { ...view, panY: clampRowPan(view.panY, ROWS * view.rowHeight, height) };
return { ...view, panY: clamp(view.panY, min, 0) };
} }
/** Zooms the rows about `y`, so the note under the pointer stays under it. */ /** 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: * 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'> { 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; const middle = (HIGHEST_NOTE - (low + high) / 2 + 0.5) * rowHeight;
return { rowHeight, panY: height / 2 - middle }; return { rowHeight, panY: height / 2 - middle };
} }
+102 -36
View File
@@ -68,6 +68,7 @@ import {
moved, moved,
placedCopies, placedCopies,
resized, resized,
roundsOf,
split, split,
trimDelta, trimDelta,
trimmed, trimmed,
@@ -124,6 +125,12 @@ interface Placed {
type Area = 'corner' | 'ruler' | 'heads' | 'lanes'; type Area = 'corner' | 'ruler' | 'heads' | 'lanes';
/**
* What a press with Shift did to the picked items: `added` the item, 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 = type Gesture =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number } | { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| { | {
@@ -144,9 +151,10 @@ type Gesture =
tracks: number; tracks: number;
moved: boolean; moved: boolean;
pointer: Vec2; pointer: Vec2;
shift: ShiftPress;
} }
| { kind: 'resize'; anchor: SongItem; by: number } | { kind: 'resize'; anchor: SongItem; by: number; shift: ShiftPress }
| { kind: 'trim'; anchor: SongItem; by: number } | { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number }; | { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
/** An asset dragged in from the panel, where it would land. */ /** An asset dragged in from the panel, where it would land. */
@@ -161,8 +169,8 @@ interface Drop {
/** /**
* The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the * The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the
* items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved * items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved
* by its body, lengthened by its right end and cut back by its left one, and goes with Delete or its * by its body and sized by either end – pulled out past what it plays, it repeats that, and an end
* menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's * snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
* selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time * selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time
* over the lanes, the tracks over their heads. * over the lanes, the tracks over their heads.
*/ */
@@ -188,6 +196,8 @@ export class SongTimeline extends Widget {
private drop: Drop | null = null; private drop: Drop | null = null;
/** The part of an item under the pointer while nothing is held. */ /** The part of an item under the pointer while nothing is held. */
private hover: { id: string; part: ItemPart } | null = null; private hover: { id: string; part: ItemPart } | null = null;
/** Where the pointer is over this widget, in its own pixels; null while it is elsewhere. */
private pointer: Vec2 | null = null;
/** Where playing starts and a paste lands, in ticks. */ /** Where playing starts and a paste lands, in ticks. */
private startTick = 0; private startTick = 0;
/** What Ctrl+C took, for any song of the project. */ /** What Ctrl+C took, for any song of the project. */
@@ -220,6 +230,11 @@ export class SongTimeline extends Widget {
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e)); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(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.onPointerRelease(() => this.onPointerUp());
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e)); this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
state.on('project', () => this.invalidate()); state.on('project', () => this.invalidate());
@@ -334,14 +349,20 @@ export class SongTimeline extends Widget {
return true; return true;
} }
/** Lands at the start of playing, on the tracks it came from; false with nothing copied. */ /**
* Lands under the pointer – the earliest item on the grid line before it, the topmost on its track –
* or, with the pointer off the lanes, at the start of playing on the tracks it came from. False with
* nothing copied.
*/
paste(): boolean { paste(): boolean {
const asset = this.asset(); const asset = this.asset();
if (!asset || this.clipboard.length === 0) return false; if (!asset || this.clipboard.length === 0) return false;
const spot = this.pasteSpot(asset);
const copies = placedCopies( const copies = placedCopies(
this.clipboard, this.clipboard,
this.startTick, spot?.tick ?? this.startTick,
asset.tracks.map((t) => t.id), asset.tracks.map((t) => t.id),
spot?.row,
); );
this.commit( this.commit(
asset, asset,
@@ -604,7 +625,10 @@ export class SongTimeline extends Widget {
edge, edge,
}; };
} else if (area === 'ruler') { } else if (area === 'ruler') {
this.gesture = { kind: 'cursor', tick: this.cursorTick(tick, songGrid(asset.props), e.altKey) }; this.gesture = {
kind: 'cursor',
tick: this.cursorTick(tick, songGrid(asset.props), e.altKey || e.shiftKey),
};
} else if (area === 'lanes') this.pressLanes(asset, e, g); } else if (area === 'lanes') this.pressLanes(asset, e, g);
this.invalidate(); this.invalidate();
} }
@@ -660,21 +684,24 @@ export class SongTimeline extends Widget {
return; return;
} }
const picked = this.pickedIds; const picked = this.pickedIds;
let shift: ShiftPress = null;
if (e.shiftKey) { if (e.shiftKey) {
this.pick( shift = picked.includes(anchor.id) ? 'unpick' : 'added';
picked.includes(anchor.id) ? picked.filter((id) => id !== anchor.id) : [...picked, anchor.id], if (shift === 'added') this.pick([...picked, anchor.id]);
); } else if (!picked.includes(anchor.id)) this.pick([anchor.id]);
return; if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0, shift };
} else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0, shift };
if (!picked.includes(anchor.id)) this.pick([anchor.id]);
if (hit.part === 'end') this.gesture = { kind: 'resize', anchor, by: 0 };
else if (hit.part === 'start') this.gesture = { kind: 'trim', anchor, by: 0 };
else { else {
const grab = xToTick(this.view, g.x) - anchor.tick; const grab = xToTick(this.view, g.x) - anchor.tick;
this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g }; this.gesture = { kind: 'move', anchor, grab, ticks: 0, tracks: 0, moved: false, pointer: g, shift };
} }
} }
/** A click with Shift that changed nothing takes a picked item out of the picked ones. */
private unpick(anchor: SongItem, shift: ShiftPress): void {
if (shift === 'unpick') this.pick(this.pickedIds.filter((id) => id !== anchor.id));
}
private onPointerMove(e: FederatedPointerEvent): void { private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const g = this.inLanes({ x: local.x, y: local.y }); const g = this.inLanes({ x: local.x, y: local.y });
@@ -692,7 +719,7 @@ export class SongTimeline extends Widget {
} }
const tick = xToTick(this.view, g.x); const tick = xToTick(this.view, g.x);
const grid = songGrid(asset.props); const grid = songGrid(asset.props);
const free = e.altKey; const free = e.altKey || e.shiftKey;
const picked = new Set(this.pickedIds); const picked = new Set(this.pickedIds);
switch (gesture.kind) { switch (gesture.kind) {
case 'pan': case 'pan':
@@ -746,12 +773,18 @@ export class SongTimeline extends Widget {
gesture.tracks = by.tracks; gesture.tracks = by.tracks;
break; break;
} }
case 'resize': case 'resize': {
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length; const tempo = songTempo(asset.props);
const rounds = roundsOf(gesture.anchor, this.periodOf(gesture.anchor, tempo), tempo);
gesture.by = draggedLength(gesture.anchor, tick, grid, free, rounds) - gesture.anchor.length;
break; break;
case 'trim': }
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, songTempo(asset.props)); case 'trim': {
const tempo = songTempo(asset.props);
const period = (item: SongItem) => this.periodOf(item, tempo);
gesture.by = trimDelta(asset.items, picked, gesture.anchor, tick, grid, free, tempo, period);
break; break;
}
case 'slider': case 'slider':
gesture.value = this.sliderAt(gesture.part, local.x); gesture.value = this.sliderAt(gesture.part, local.x);
this.onTracksPreview(this.shownTracks(asset)); this.onTracksPreview(this.shownTracks(asset));
@@ -809,7 +842,10 @@ export class SongTimeline extends Widget {
}, },
picked.size === 1 ? 'Move item' : 'Move items', picked.size === 1 ? 'Move item' : 'Move items',
); );
else if (!gesture.moved) this.pick([gesture.anchor.id]); else if (!gesture.moved) {
if (gesture.shift === null) this.pick([gesture.anchor.id]);
else this.unpick(gesture.anchor, gesture.shift);
}
break; break;
case 'resize': case 'resize':
if (gesture.by !== 0) if (gesture.by !== 0)
@@ -818,14 +854,16 @@ export class SongTimeline extends Widget {
{ items: resized(asset.items, picked, gesture.by) }, { items: resized(asset.items, picked, gesture.by) },
picked.size === 1 ? 'Resize item' : 'Resize items', picked.size === 1 ? 'Resize item' : 'Resize items',
); );
else this.unpick(gesture.anchor, gesture.shift);
break; break;
case 'trim': case 'trim':
if (gesture.by !== 0) if (gesture.by !== 0)
this.commit( this.commit(
asset, asset,
{ items: trimmed(asset.items, picked, gesture.by, songTempo(asset.props)) }, { items: this.trimmedItems(asset, picked, gesture.by) },
picked.size === 1 ? 'Trim item' : 'Trim items', picked.size === 1 ? 'Trim item' : 'Trim items',
); );
else this.unpick(gesture.anchor, gesture.shift);
break; break;
case 'slider': { case 'slider': {
const tracks = this.shownTracksWith(asset, gesture); const tracks = this.shownTracksWith(asset, gesture);
@@ -880,7 +918,21 @@ export class SongTimeline extends Widget {
this.invalidate(); 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 lanes: the grid line before it and its track; else null. */
private pasteSpot(asset: SongAsset): { tick: number; row: number } | null {
const p = this.pointer;
if (!p || p.x > this.w || p.y > this.h || this.areaAt(p) !== 'lanes') return null;
const g = this.inLanes(p);
const tick = xToTick(this.view, g.x);
// Left of tick 0 nothing lands, as nothing is dropped there.
if (tick < 0) return null;
return {
tick: snapDown(tick, songGrid(asset.props)),
row: nearestTrack(this.view, asset.tracks.length, g.y),
};
}
/** 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 { private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid)); return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
} }
@@ -985,10 +1037,26 @@ export class SongTimeline extends Widget {
g.tracks, g.tracks,
); );
if (g?.kind === 'resize') return resized(asset.items, picked, g.by); if (g?.kind === 'resize') return resized(asset.items, picked, g.by);
if (g?.kind === 'trim') return trimmed(asset.items, picked, g.by, songTempo(asset.props)); if (g?.kind === 'trim') return this.trimmedItems(asset, picked, g.by);
return asset.items; return asset.items;
} }
private trimmedItems(asset: SongAsset, picked: ReadonlySet<string>, by: number): SongItem[] {
const tempo = songTempo(asset.props);
return trimmed(asset.items, picked, by, tempo, (item) => this.periodOf(item, tempo));
}
/** How long one round of what an item plays is, in ticks: its pattern, or its sound once loaded. */
private periodOf(item: SongItem, tempo: number): number | null {
if (item.kind === 'pattern') {
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
return pattern ? patternLength(pattern) : null;
}
const sound = findAssetOf(this.state.project, item.asset, 'sound');
const seconds = sound?.file ? soundLibrary.duration(sound.file) : null;
return seconds ? ticksOfSeconds(seconds, tempo) : null;
}
private shownTracks(asset: SongAsset): SongTrack[] { private shownTracks(asset: SongAsset): SongTrack[] {
const g = this.gesture; const g = this.gesture;
return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks; return g?.kind === 'slider' ? this.shownTracksWith(asset, g) : asset.tracks;
@@ -1013,16 +1081,11 @@ export class SongTimeline extends Widget {
/** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */ /** Where what an item plays starts over inside it: each round of its pattern, or of its sound once loaded. */
private seamsOf(item: SongItem): number[] { private seamsOf(item: SongItem): number[] {
if (item.kind === 'pattern') {
const pattern = findAssetOf(this.state.project, item.asset, 'pattern');
return pattern ? loopSeams(item, patternLength(pattern), item.offset) : [];
}
const sound = findAssetOf(this.state.project, item.asset, 'sound');
const seconds = sound?.file ? soundLibrary.duration(sound.file) : null;
const asset = this.asset(); const asset = this.asset();
if (!seconds || !asset) return []; if (!asset) return [];
const tempo = songTempo(asset.props); const tempo = songTempo(asset.props);
return loopSeams(item, ticksOfSeconds(seconds, tempo), ticksOfSeconds(item.offset % seconds, tempo)); const rounds = roundsOf(item, this.periodOf(item, tempo), tempo);
return rounds ? loopSeams(item, rounds.period, item.tick - rounds.base) : [];
} }
private labelOf(item: SongItem): string { private labelOf(item: SongItem): string {
@@ -1146,17 +1209,20 @@ export class SongTimeline extends Widget {
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 }); .fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 }); this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
}); });
// Above the first track and below the last, panned to, is empty.
const top = Math.max(0, trackTop(this.view, 0));
const bottom = Math.min(lh, trackTop(this.view, tracks.length)); const bottom = Math.min(lh, trackTop(this.view, tracks.length));
if (bottom <= top) return;
const alpha = [0.9, 0.45, 0.2]; const alpha = [0.9, 0.45, 0.2];
for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw)) for (const line of timeLines(this.view, songMeter(asset.props), songGrid(asset.props), lw))
this.lanesG.rect(line.x, 0, 1, bottom).fill({ color: c.border, alpha: alpha[line.level] }); this.lanesG.rect(line.x, top, 1, bottom - top).fill({ color: c.border, alpha: alpha[line.level] });
const end = tickToX(this.view, length); const end = tickToX(this.view, length);
if (end < lw) if (end < lw)
this.lanesG this.lanesG
.rect(Math.max(0, end), 0, lw - Math.max(0, end), bottom) .rect(Math.max(0, end), top, lw - Math.max(0, end), bottom - top)
.fill({ color: 0x000000, alpha: 0.2 }); .fill({ color: 0x000000, alpha: 0.2 });
const zero = Math.min(lw, tickToX(this.view, 0)); const zero = Math.min(lw, tickToX(this.view, 0));
if (zero > 0) this.lanesG.rect(0, 0, zero, bottom).fill({ color: 0x000000, alpha: 0.4 }); if (zero > 0) this.lanesG.rect(0, top, zero, bottom - top).fill({ color: 0x000000, alpha: 0.4 });
} }
private drawItems(asset: SongAsset, lw: number, lh: number): void { private drawItems(asset: SongAsset, lw: number, lh: number): void {
+85 -16
View File
@@ -5,9 +5,31 @@ import { snapDown, snapNear } from '../pattern/rollEdit';
/** /**
* What the Song tab does to items (#334), as plain functions on lists so a test can check them. * What the Song tab does to items (#334), 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; an
* end of an item snaps to where what it plays starts over, too.
*/ */
/** Where what an item plays starts over: every `period` ticks on from `base`, both ways. */
export interface Rounds {
base: number;
period: number;
}
/** An item's rounds, given how long one is in ticks; null while that is not known (a sound not loaded). */
export function roundsOf(item: SongItem, period: number | null, tempo: number): Rounds | null {
if (period === null || !(period > 0)) return null;
const into = offsetTicks(item, tempo);
return { base: item.tick - (((into % period) + period) % period), period };
}
/** The grid line or the start of a round nearest a tick, whichever is nearer; may be between ticks. */
export function snapEdge(tick: number, grid: number, rounds: Rounds | null): number {
const line = snapNear(tick, grid);
if (!rounds) return line;
const seam = rounds.base + Math.round((tick - rounds.base) / rounds.period) * rounds.period;
return Math.abs(seam - tick) < Math.abs(line - tick) ? seam : line;
}
/** /**
* How far a drag moves the picked items: the grabbed one's start lands on the grid (or the tick), * How far a drag moves the picked items: the grabbed one's start lands on the grid (or the tick),
* across as many tracks as the pointer went, and none goes before tick 0 or off the tracks. * across as many tracks as the pointer went, and none goes before tick 0 or off the tracks.
@@ -71,12 +93,19 @@ export function moved(
} }
/** /**
* The length the grabbed item takes when its end is dragged to a tick: to the grid line nearest the * The length the grabbed item takes when its end is dragged to a tick: to the grid line or the end of
* pointer, but never back to its own start – then to the first grid line after it. * a round nearest the pointer, but never back to its own start – then to the first grid line after
* it. The end of a round between two ticks is cut to the tick before, so nothing of the next sounds.
*/ */
export function draggedLength(anchor: SongItem, pointerTick: number, grid: number, free: boolean): number { export function draggedLength(
anchor: SongItem,
pointerTick: number,
grid: number,
free: boolean,
rounds: Rounds | null = null,
): number {
if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick); if (free) return Math.max(1, Math.round(pointerTick) - anchor.tick);
const end = snapNear(pointerTick, grid); const end = Math.floor(snapEdge(pointerTick, grid, rounds) + 1e-6);
return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick; return end > anchor.tick ? end - anchor.tick : snapDown(anchor.tick, grid) + grid - anchor.tick;
} }
@@ -90,9 +119,14 @@ function offsetTicks(item: SongItem, tempo: number): number {
return item.kind === 'sound' ? ticksOfSeconds(item.offset, tempo) : item.offset; return item.kind === 'sound' ? ticksOfSeconds(item.offset, tempo) : item.offset;
} }
/** How long one round of an item is in ticks, null where that is not known. */
export type PeriodOf = (item: SongItem) => number | null;
/** /**
* How far the start edge of the grabbed item moves when it is dragged to a tick, held so that no * How far the start edge of the grabbed item moves when it is dragged to a tick: to the grid line or
* picked item gets shorter than a tick, starts before 0 or before the beginning of what it plays. * the start of a round nearest the pointer. Held so that no picked item gets shorter than a tick or
* starts before 0. One whose round is known goes on left as far as that, repeating what it plays;
* one whose round is not stops at the beginning of what it plays.
*/ */
export function trimDelta( export function trimDelta(
items: readonly SongItem[], items: readonly SongItem[],
@@ -102,29 +136,55 @@ export function trimDelta(
grid: number, grid: number,
free: boolean, free: boolean,
tempo: number, tempo: number,
periodOf: PeriodOf = () => null,
): number { ): number {
const start = free ? Math.round(pointerTick) : snapNear(pointerTick, grid); const rounds = roundsOf(anchor, periodOf(anchor), tempo);
const start = Math.round(free ? pointerTick : snapEdge(pointerTick, grid, rounds));
let by = start - anchor.tick; let by = start - anchor.tick;
for (const item of items) { for (const item of items) {
if (!picked.has(item.id)) continue; if (!picked.has(item.id)) continue;
by = Math.min(by, item.length - 1); by = Math.min(by, item.length - 1);
by = Math.max(by, -item.tick, -Math.floor(offsetTicks(item, tempo) + 1e-6)); by = Math.max(by, -item.tick);
if (roundsOf(item, periodOf(item), tempo) === null)
by = Math.max(by, -Math.floor(offsetTicks(item, tempo) + 1e-6));
} }
return by || 0; return by || 0;
} }
/** The picked items with their start edge moved by `by` ticks, what they play staying where it was. */ /**
* An offset pulled out before what it plays taken round to the end of a round, and one within
* `slack` of a round's start put right on it, so a start put back on a round between two ticks plays
* from the very beginning. An offset past a round is otherwise kept: the pattern may grow.
*/
function withinRound(offset: number, period: number, slack: number): number {
let into = offset < 0 ? offset + Math.ceil(-offset / period - 1e-9) * period : offset;
const round = Math.round(into / period) * period;
if (Math.abs(into - round) <= slack) into = round;
return Math.max(0, into);
}
/**
* The picked items with their start edge moved by `by` ticks, what they play staying where it was.
* Where a round is known the offset goes round it, so an item pulled out left repeats from its end.
*/
export function trimmed( export function trimmed(
items: readonly SongItem[], items: readonly SongItem[],
picked: ReadonlySet<string>, picked: ReadonlySet<string>,
by: number, by: number,
tempo: number, tempo: number,
periodOf: PeriodOf = () => null,
): SongItem[] { ): SongItem[] {
if (by === 0) return [...items]; if (by === 0) return [...items];
return items.map((i) => { return items.map((i) => {
if (!picked.has(i.id)) return i; if (!picked.has(i.id)) return i;
const offset = const sound = i.kind === 'sound';
i.kind === 'sound' ? Math.max(0, i.offset + secondsOfTicks(by, tempo)) : Math.max(0, i.offset + by); const period = periodOf(i);
let offset = sound ? i.offset + secondsOfTicks(by, tempo) : i.offset + by;
if (period !== null && period > 0)
offset = sound
? withinRound(offset, secondsOfTicks(period, tempo), secondsOfTicks(0.5, tempo) + 1e-9)
: withinRound(offset, period, 0);
else offset = Math.max(0, offset);
return { ...i, tick: i.tick + by, length: Math.max(1, i.length - by), offset }; return { ...i, tick: i.tick + by, length: Math.max(1, i.length - by), offset };
}); });
} }
@@ -167,20 +227,29 @@ export function duplicateOffset(
return Math.max(grid, Math.ceil((end - first) / grid) * grid); return Math.max(grid, Math.ceil((end - first) / grid) * grid);
} }
/** Copies of items with ids of their own, on their tracks, moved so the earliest starts at `at`. */ /**
* Copies of items with ids of their own, moved so the earliest starts at `at`: on their tracks, or,
* given `row`, the topmost on that track and the others as far below as they were, as far as there
* are tracks. An item whose track is gone counts as on the first.
*/
export function placedCopies( export function placedCopies(
items: readonly SongItem[], items: readonly SongItem[],
at: number, at: number,
trackIds: readonly string[], trackIds: readonly string[],
row?: number,
): SongItem[] { ): SongItem[] {
if (items.length === 0) return []; if (items.length === 0) return [];
const first = Math.min(...items.map((i) => i.tick)); const first = Math.min(...items.map((i) => i.tick));
const known = new Set(trackIds); const index = new Map(trackIds.map((id, i) => [id, i]));
return items.map((i) => ({ const rows = items.map((i) => index.get(i.track) ?? 0);
const top = Math.min(...rows);
const bottom = Math.max(...rows);
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
return items.map((i, n) => ({
...i, ...i,
id: newId(), id: newId(),
tick: Math.max(0, at) + (i.tick - first), tick: Math.max(0, at) + (i.tick - first),
track: known.has(i.track) ? i.track : (trackIds[0] ?? i.track), track: trackIds[rows[n]! + shift] ?? i.track,
})); }));
} }
+4 -5
View File
@@ -1,7 +1,7 @@
import type { SongItem, SongTrack } from '../../core/music/song'; import type { SongItem, SongTrack } from '../../core/music/song';
import type { Vec2 } from '../../core/model/Properties'; import type { Vec2 } from '../../core/model/Properties';
import { clamp } from '../../ui'; import { clamp } from '../../ui';
import { type TimeView, tickToX, xToTick } from '../music/timeView'; import { type TimeView, clampRowPan, tickToX, xToTick } from '../music/timeView';
/** /**
* The geometry of the Song tab (#334), apart from Pixi so a test can drive it: the tracks stacked * The geometry of the Song tab (#334), apart from Pixi so a test can drive it: the tracks stacked
@@ -16,7 +16,7 @@ export interface SongView extends TimeView {
export const HEAD_WIDTH = 176; export const HEAD_WIDTH = 176;
export const TRACK_HEIGHT = 52; export const TRACK_HEIGHT = 52;
export const MIN_TRACK_HEIGHT = 28; export const MIN_TRACK_HEIGHT = 28;
export const MAX_TRACK_HEIGHT = 160; export const MAX_TRACK_HEIGHT = 320;
/** A track lower than this has no room for the volume and pan sliders in its head. */ /** A track lower than this has no room for the volume and pan sliders in its head. */
export const SLIDERS_MIN_HEIGHT = 46; export const SLIDERS_MIN_HEIGHT = 46;
/** How close to an end of an item a press takes that end. */ /** How close to an end of an item a press takes that end. */
@@ -147,10 +147,9 @@ export function peakOver(peaks: ArrayLike<number>, from: number, to: number): nu
return peak; return peak;
} }
/** Keeps the tracks in reach: nothing to scroll to above the first or below the last. */ /** Keeps the tracks in reach: half the height past the first or the last at most. */
export function clampTracks<V extends SongView>(view: V, count: number, height: number): V { export function clampTracks<V extends SongView>(view: V, count: number, height: number): V {
const content = count * view.trackHeight; const panY = clampRowPan(view.panY, count * view.trackHeight, height);
const panY = clamp(view.panY, Math.min(0, height - content), 0);
return panY === view.panY ? view : { ...view, panY }; return panY === view.panY ? view : { ...view, panY };
} }
+20 -6
View File
@@ -103,20 +103,23 @@ describe('rows of the piano roll (#333)', () => {
expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull(); expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull();
}); });
it('keeps the rows on screen and zooms them about the pointer', () => { it('pans the rows half the height past either end at most and zooms them about the pointer', () => {
expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0); expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(50);
expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - ROWS * 10); expect(clampRows({ ...VIEW, panY: 500 }, 300).panY).toBe(150);
expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - 150 - ROWS * 10);
const zoomed = zoomRows(VIEW, 2, 25); const zoomed = zoomRows(VIEW, 2, 25);
expect(zoomed.rowHeight).toBe(20); expect(zoomed.rowHeight).toBe(20);
expect(noteAtY(zoomed, 25)).toBe(58); expect(noteAtY(zoomed, 25)).toBe(58);
expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(28); expect(zoomRows(VIEW, 100, 0).rowHeight).toBe(64);
}); });
it('centres the notes it is fitted to, lower rows where they do not fit', () => { it('centres the notes it is fitted to, their rows as high as fills the height', () => {
const fitted = centreRows(60, 64, 200); const fitted = centreRows(60, 64, 200);
expect(fitted.rowHeight).toBe(14); // Five notes and a row of room above and below fill 200 px: the rows grow past the default.
expect(fitted.rowHeight).toBeCloseTo(200 / 7);
const view = { ...VIEW, ...fitted }; const view = { ...VIEW, ...fitted };
expect(noteAtY(view, 100)).toBe(62); expect(noteAtY(view, 100)).toBe(62);
expect(centreRows(60, 60, 400).rowHeight).toBe(64);
expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27); expect(centreRows(24, 48, 200).rowHeight).toBeCloseTo(200 / 27);
expect(centreRows(0, 127, 200).rowHeight).toBe(6); expect(centreRows(0, 127, 200).rowHeight).toBe(6);
}); });
@@ -221,6 +224,17 @@ describe('editing in the piano roll (#333)', () => {
expect(copies.map((n) => n.id)).not.toContain('a'); expect(copies.map((n) => n.id)).not.toContain('a');
}); });
it('pastes under the pointer: the highest copied note on its row, all kept on the keyboard', () => {
const copied = [note('a', 480, 120, 60), note('b', 720, 60, 64)];
expect(placedCopies(copied, 960, 72).map((n) => [n.tick, n.note])).toEqual([
[960, 68],
[1200, 72],
]);
// Up at the top the lowest would still fit, but the highest would not: held to 127.
expect(placedCopies(copied, 0, 200).map((n) => n.note)).toEqual([123, 127]);
expect(placedCopies(copied, 0, 2).map((n) => n.note)).toEqual([0, 4]);
});
it('sets the grabbed velocity and moves the other picked ones by as much', () => { it('sets the grabbed velocity and moves the other picked ones by as much', () => {
const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)]; const notes = [note('a', 0, 120, 60, 0.5), note('b', 0, 120, 62, 0.9), note('c', 0, 120, 64, 0.2)];
expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([ expect(withVelocity(notes, new Set(['a', 'b']), notes[0]!, 0.7).map((n) => n.velocity)).toEqual([
+47 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { newItem, type SongItem } from '../../src/core/music/song'; import { newItem, type SongItem } from '../../src/core/music/song';
import { secondsOfTicks } from '../../src/core/music/time';
import { import {
clampMove, clampMove,
draggedLength, draggedLength,
@@ -8,6 +9,8 @@ import {
moveDelta, moveDelta,
moved, moved,
placedCopies, placedCopies,
roundsOf,
snapEdge,
split, split,
trimDelta, trimDelta,
trimmed, trimmed,
@@ -93,6 +96,40 @@ describe('moving and sizing items (#334)', () => {
expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 }); expect(trimmed([pattern], new Set(['p']), -120, 120)[0]).toMatchObject({ tick: BAR - 120, offset: 0 });
}); });
it('pulls an item out left past what it plays, which then repeats from its end', () => {
// 0.5 s at 120 BPM is 480 ticks; a pattern a bar long.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const pattern = item('p', 't1', BAR, BAR);
const period = (i: SongItem) => (i.kind === 'sound' ? 480 : BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120, period)).toBe(-BAR);
expect(trimDelta([sound], new Set(['s']), sound, 0, 120, false, 120)).toBe(0);
const [s] = trimmed([sound], new Set(['s']), -240, 120, period);
expect([s!.tick, s!.length, s!.offset]).toEqual([BAR - 240, BAR + 240, 0.25]);
const [p] = trimmed([pattern], new Set(['p']), -480, 120, period);
expect([p!.tick, p!.length, p!.offset]).toEqual([BAR - 480, BAR + 480, BAR - 480]);
});
it('snaps an end to where what the item plays starts over, as well as to the grid', () => {
// A sound 700.4 ticks long: its rounds end between two ticks.
const sound = item('s', 't1', BAR, BAR, { kind: 'sound', offset: 0 });
const rounds = roundsOf(sound, 700.4, 120)!;
expect(rounds).toEqual({ base: BAR, period: 700.4 });
expect(snapEdge(BAR + 1390, BAR, rounds)).toBeCloseTo(BAR + 1400.8);
expect(snapEdge(BAR + 1900, BAR, rounds)).toBe(2 * BAR);
expect(snapEdge(BAR + 1390, BAR, null)).toBe(2 * BAR);
// The end is cut to the tick before, so nothing of the next round sounds.
expect(draggedLength(sound, BAR + 1390, BAR, false, rounds)).toBe(1400);
// The start put back on a round plays from the very beginning, not a hair into it.
const cut = item('c', 't1', BAR + 100, 2000, { kind: 'sound', offset: 100 / 960 });
const picked = new Set(['c']);
const by = trimDelta([cut], picked, cut, BAR + 705, 480, false, 120, () => 700.4);
expect(by).toBe(600);
expect(trimmed([cut], picked, by, 120, () => 700.4)[0]!.offset).toBe(secondsOfTicks(700.4, 120));
// An offset a whole pattern in stays so: the pattern may grow later.
const far = item('f', 't1', BAR, 2 * BAR);
expect(trimmed([far], new Set(['f']), BAR, 120, () => BAR)[0]!.offset).toBe(BAR);
});
it('splits the picked items the point falls inside, and gives the right-hand parts', () => { it('splits the picked items the point falls inside, and gives the right-hand parts', () => {
const cut = split(items, new Set(['a', 'b']), BAR + 480, 120); const cut = split(items, new Set(['a', 'b']), BAR + 480, 120);
expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([ expect(cut.items.map((i) => [i.track, i.tick, i.length, i.offset])).toEqual([
@@ -111,6 +148,9 @@ describe('moving and sizing items (#334)', () => {
[480 + 2 * BAR, 't2'], [480 + 2 * BAR, 't2'],
]); ]);
expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true); expect(copies.every((c) => c.id !== 'a' && c.id !== 'b')).toBe(true);
// Under the pointer: the topmost on its track, as far down as there are tracks.
expect(placedCopies(items, 0, TRACKS, 2).map((i) => i.track)).toEqual(['t2', 't3']);
expect(placedCopies(items, 0, TRACKS, 0).map((i) => i.track)).toEqual(['t1', 't2']);
}); });
it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => { it('makes a dropped pattern as long as it is, a sound as long as it plays at the tempo, else a bar', () => {
@@ -138,9 +178,12 @@ describe('where things are in the Song tab (#334)', () => {
]); ]);
}); });
it('scrolls no further than the tracks', () => { it('scrolls half the height past the first or the last track at most', () => {
expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0); expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(40);
expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT); expect(clampTracks({ ...view, panY: 900 }, 3, 400).panY).toBe(200);
// Tracks that fit with room to spare are not pushed up off the top.
expect(clampTracks({ ...view, panY: -40 }, 1, 400).panY).toBe(0);
expect(clampTracks({ ...view, panY: -5000 }, 10, 400).panY).toBe(400 - 200 - 10 * TRACK_HEIGHT);
}); });
it('tells the parts of a head apart and reads a slider from left to right', () => { it('tells the parts of a head apart and reads a slider from left to right', () => {
@@ -164,7 +207,7 @@ describe('where things are in the Song tab (#334)', () => {
expect(trackAtY(zoomed, 10, 150)).toBe(trackAtY({ ...view, panY: -20 }, 10, 150)); 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, 100, 0).trackHeight).toBe(MAX_TRACK_HEIGHT);
expect(zoomTracks(view, 0.01, 0).trackHeight).toBe(MIN_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); expect(clampTracks({ ...zoomed, panY: -9999 }, 10, 400).panY).toBe(400 - 200 - 10 * 2 * TRACK_HEIGHT);
expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({ expect(heldSongView({ panX: -5, pxPerTick: 0.3, panY: -10 })).toEqual({
panX: -5, panX: -5,
pxPerTick: 0.3, pxPerTick: 0.3,
+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, free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720,
JSON.stringify({ free, back }), 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. // 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)'); 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, startSet === 1920 && pasted.length === 8 && pastedIds.length === 4 && Math.min(...pastedTicks) === 1920,
JSON.stringify({ startSet, count: pasted.length, pastedTicks }), 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. // 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)'); p = await json('__gm.pattern.gridAt(1980, 60)');
@@ -317,13 +351,41 @@ export async function pattern(t) {
const countPanned = (await notes()).length; const countPanned = (await notes()).length;
await click(zero.x - 20, zero.y); await click(zero.x - 20, zero.y);
const countLeft = (await notes()).length; 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()'); p = await json('__gm.pattern.fitAt()');
await click(p.x, p.y); await click(p.x, p.y);
const fitted = await json('__gm.pattern.view()');
check( 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', '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(panned.panX - (area.w - 84) / 2) < 1 &&
JSON.stringify({ panned, area, countPanned, countLeft }), 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)'); p = await json('__gm.pattern.loopAt(1920)');
to = await json('__gm.pattern.loopAt(2880)'); to = await json('__gm.pattern.loopAt(2880)');
await drag(p.x, p.y, to.x, to.y); 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 * Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds
* three more. A pattern and a * three more. A pattern and a
* sound dragged in from the Assets panel land as items where the preview showed them; an item's * 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 * 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 * 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 * 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), 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. // 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')`); p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`);
await click(p.x, p.y); await click(p.x, p.y);