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;
}
/** 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. */
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) };
+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 };
}
+102 -36
View File
@@ -68,6 +68,7 @@ import {
moved,
placedCopies,
resized,
roundsOf,
split,
trimDelta,
trimmed,
@@ -124,6 +125,12 @@ interface Placed {
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 =
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
| {
@@ -144,9 +151,10 @@ type Gesture =
tracks: number;
moved: boolean;
pointer: Vec2;
shift: ShiftPress;
}
| { kind: 'resize'; anchor: SongItem; by: number }
| { kind: 'trim'; anchor: SongItem; by: number }
| { kind: 'resize'; anchor: SongItem; by: number; shift: ShiftPress }
| { kind: 'trim'; anchor: SongItem; by: number; shift: ShiftPress }
| { kind: 'slider'; track: string; part: 'volume' | 'pan'; value: number };
/** 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
* 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
* menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's
* by its body and sized by either end – pulled out past what it plays, it repeats that, and an end
* 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
* over the lanes, the tracks over their heads.
*/
@@ -188,6 +196,8 @@ export class SongTimeline extends Widget {
private drop: Drop | null = null;
/** The part of an item under the pointer while nothing is held. */
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. */
private startTick = 0;
/** 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('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', () => this.invalidate());
@@ -334,14 +349,20 @@ export class SongTimeline extends Widget {
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 {
const asset = this.asset();
if (!asset || this.clipboard.length === 0) return false;
const spot = this.pasteSpot(asset);
const copies = placedCopies(
this.clipboard,
this.startTick,
spot?.tick ?? this.startTick,
asset.tracks.map((t) => t.id),
spot?.row,
);
this.commit(
asset,
@@ -604,7 +625,10 @@ export class SongTimeline extends Widget {
edge,
};
} 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);
this.invalidate();
}
@@ -660,21 +684,24 @@ export class SongTimeline extends Widget {
return;
}
const picked = this.pickedIds;
let shift: ShiftPress = null;
if (e.shiftKey) {
this.pick(
picked.includes(anchor.id) ? picked.filter((id) => id !== anchor.id) : [...picked, anchor.id],
);
return;
}
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 };
shift = picked.includes(anchor.id) ? 'unpick' : 'added';
if (shift === 'added') this.pick([...picked, anchor.id]);
} else if (!picked.includes(anchor.id)) this.pick([anchor.id]);
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 };
else {
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 {
const local = this.toLocal(e.global);
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 grid = songGrid(asset.props);
const free = e.altKey;
const free = e.altKey || e.shiftKey;
const picked = new Set(this.pickedIds);
switch (gesture.kind) {
case 'pan':
@@ -746,12 +773,18 @@ export class SongTimeline extends Widget {
gesture.tracks = by.tracks;
break;
}
case 'resize':
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
case 'resize': {
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;
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;
}
case 'slider':
gesture.value = this.sliderAt(gesture.part, local.x);
this.onTracksPreview(this.shownTracks(asset));
@@ -809,7 +842,10 @@ export class SongTimeline extends Widget {
},
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;
case 'resize':
if (gesture.by !== 0)
@@ -818,14 +854,16 @@ export class SongTimeline extends Widget {
{ items: resized(asset.items, picked, gesture.by) },
picked.size === 1 ? 'Resize item' : 'Resize items',
);
else this.unpick(gesture.anchor, gesture.shift);
break;
case 'trim':
if (gesture.by !== 0)
this.commit(
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',
);
else this.unpick(gesture.anchor, gesture.shift);
break;
case 'slider': {
const tracks = this.shownTracksWith(asset, gesture);
@@ -880,7 +918,21 @@ export class SongTimeline 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 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 {
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
}
@@ -985,10 +1037,26 @@ export class SongTimeline extends Widget {
g.tracks,
);
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;
}
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[] {
const g = this.gesture;
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. */
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();
if (!seconds || !asset) return [];
if (!asset) return [];
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 {
@@ -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 });
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));
if (bottom <= top) return;
const alpha = [0.9, 0.45, 0.2];
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);
if (end < lw)
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 });
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 {
+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.
* 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),
* 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
* pointer, but never back to its own start – then to the first grid line after it.
* The length the grabbed item takes when its end is dragged to a tick: to the grid line or the end of
* 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);
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;
}
@@ -90,9 +119,14 @@ function offsetTicks(item: SongItem, tempo: number): number {
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
* picked item gets shorter than a tick, starts before 0 or before the beginning of what it plays.
* How far the start edge of the grabbed item moves when it is dragged to a tick: to the grid line or
* 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(
items: readonly SongItem[],
@@ -102,29 +136,55 @@ export function trimDelta(
grid: number,
free: boolean,
tempo: number,
periodOf: PeriodOf = () => null,
): 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;
for (const item of items) {
if (!picked.has(item.id)) continue;
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;
}
/** 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(
items: readonly SongItem[],
picked: ReadonlySet<string>,
by: number,
tempo: number,
periodOf: PeriodOf = () => null,
): SongItem[] {
if (by === 0) return [...items];
return items.map((i) => {
if (!picked.has(i.id)) return i;
const offset =
i.kind === 'sound' ? Math.max(0, i.offset + secondsOfTicks(by, tempo)) : Math.max(0, i.offset + by);
const sound = i.kind === 'sound';
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 };
});
}
@@ -167,20 +227,29 @@ export function duplicateOffset(
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(
items: readonly SongItem[],
at: number,
trackIds: readonly string[],
row?: number,
): SongItem[] {
if (items.length === 0) return [];
const first = Math.min(...items.map((i) => i.tick));
const known = new Set(trackIds);
return items.map((i) => ({
const index = new Map(trackIds.map((id, i) => [id, 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,
id: newId(),
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 { Vec2 } from '../../core/model/Properties';
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
@@ -16,7 +16,7 @@ export interface SongView extends TimeView {
export const HEAD_WIDTH = 176;
export const TRACK_HEIGHT = 52;
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. */
export const SLIDERS_MIN_HEIGHT = 46;
/** 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;
}
/** 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 {
const content = count * view.trackHeight;
const panY = clamp(view.panY, Math.min(0, height - content), 0);
const panY = clampRowPan(view.panY, count * view.trackHeight, height);
return panY === view.panY ? view : { ...view, panY };
}