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

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

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

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

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

No files matched your search

+79 -12
View File
@@ -38,6 +38,7 @@ import type { EditorState } from '../state';
import {
clampShift,
draggedLength,
draggedStart,
drawnNote,
duplicateOffset,
moveDelta,
@@ -46,12 +47,16 @@ import {
shifted,
snapDown,
snapNear,
trimmed,
withVelocity,
} from './rollEdit';
import {
centreRows,
clampRows,
DEFAULT_ROW_HEIGHT,
END_GRIP,
type NoteBox,
type NoteHit,
noteAt,
noteAtY,
noteBox,
@@ -106,6 +111,7 @@ type Gesture =
pointer: Vec2;
}
| { kind: 'resize'; anchor: PatternNote; by: number; moved: boolean }
| { kind: 'trim'; anchor: PatternNote; by: number }
| { kind: 'erase'; erased: Set<string> }
| { kind: 'velocity'; anchor: PatternNote; value: number };
@@ -121,8 +127,8 @@ function mix(a: number, b: number, t: number): number {
/**
* The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid
* between them and their velocities in a lane below. A click into the grid draws a note, a drag spans
* a band that picks notes; a note is dragged by its body, lengthened by its right end, and goes with a
* right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
* a band that picks notes; a note is dragged by its body, sized by either end, which lights up under
* the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip
* along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys.
*/
export class PianoRoll extends Widget {
@@ -149,6 +155,8 @@ export class PianoRoll extends Widget {
private needsFit = true;
private assetId = '';
private gesture: Gesture | null = null;
/** The part of a note under the pointer while nothing is held. */
private hover: NoteHit | 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. */
@@ -217,6 +225,7 @@ export class PianoRoll extends Widget {
if (this.assetId === assetId) return;
this.endGesture();
this.assetId = assetId;
this.hover = null;
this.picked.clear();
this.startTick = 0;
this.needsFit = true;
@@ -419,6 +428,8 @@ export class PianoRoll extends Widget {
playhead: number | null;
loop: LoopRange | null;
keys: number | null;
hover: NoteHit | null;
cursor: string;
} {
const g = this.gesture;
return {
@@ -427,6 +438,8 @@ export class PianoRoll extends Widget {
playhead: this.playhead,
loop: this.shownLoop(),
keys: g?.kind === 'key' ? g.note : null,
hover: this.hover,
cursor: String(this.cursor ?? ''),
};
}
@@ -531,6 +544,10 @@ export class PianoRoll extends Widget {
this.gesture = { kind: 'resize', anchor, by: 0, moved: false };
return;
}
if (hit.part === 'start') {
this.gesture = { kind: 'trim', anchor, by: 0 };
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.onPreview(anchor.note, false);
@@ -539,20 +556,27 @@ export class PianoRoll extends Widget {
private onPointerMove(e: FederatedPointerEvent): void {
const gesture = this.gesture;
const asset = this.asset();
if (!gesture || !asset) return;
if (!asset) return;
const local = this.toLocal(e.global);
const g = this.inGrid({ x: local.x, y: local.y });
if (!gesture) {
this.followHover(asset, local, g);
return;
}
const tick = xToTick(this.view, g.x);
const grid = patternGrid(asset.props);
const free = e.altKey;
switch (gesture.kind) {
case 'pan':
this.view = clampRows(
clampTimePan({
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
}),
clampTimePan(
{
...this.view,
panX: gesture.panX + (local.x - gesture.pointer.x),
panY: gesture.panY + (local.y - gesture.pointer.y),
},
this.gridWidth,
),
this.gridHeight,
);
break;
@@ -596,6 +620,9 @@ export class PianoRoll extends Widget {
gesture.moved = true;
gesture.by = draggedLength(gesture.anchor, tick, grid, free) - gesture.anchor.length;
break;
case 'trim':
gesture.by = draggedStart(gesture.anchor, tick, grid, free) - gesture.anchor.tick;
break;
case 'erase':
this.erase(asset, g);
break;
@@ -650,6 +677,16 @@ export class PianoRoll extends Widget {
this.lastLength = gesture.anchor.length + gesture.by;
}
break;
case 'trim':
if (gesture.by !== 0) {
this.commit(
asset,
trimmed(asset.notes, this.picked, gesture.by),
this.picked.size === 1 ? 'Resize note' : 'Resize notes',
);
this.lastLength = gesture.anchor.length - gesture.by;
}
break;
case 'erase':
if (gesture.erased.size > 0) {
this.commit(
@@ -680,7 +717,8 @@ export class PianoRoll extends Widget {
private draw(asset: PatternAsset, at: Vec2, grid: number, free: boolean): void {
const note = noteAtY(this.view, at.y);
if (note === null) return;
// Left of tick 0 is there to look at, not to draw on.
if (note === null || xToTick(this.view, at.x) < 0) return;
const drawn = drawnNote(
xToTick(this.view, at.x),
note,
@@ -702,9 +740,19 @@ export class PianoRoll extends Widget {
if (hit) this.gesture.erased.add(hit.id);
}
/** Where a press or a drag on the ruler puts the start: on the grid line before it, with Alt on the tick. */
/** The part of a note under the pointer: an end it can be dragged by shows a resize cursor and lights up. */
private followHover(asset: PatternAsset, local: Vec2, g: Vec2): void {
const hit = this.areaAt(local) === 'grid' && local.x <= this.w ? noteAt(this.view, asset.notes, g) : null;
const cursor = hit && hit.part !== 'body' ? 'ew-resize' : 'default';
if (this.cursor !== cursor) this.cursor = cursor;
if (JSON.stringify(hit) === JSON.stringify(this.hover)) return;
this.hover = hit;
this.invalidate();
}
/** Where a press or a drag on the ruler puts the start: on the nearest grid line, with Alt on the tick. */
private cursorTick(tick: number, grid: number, free: boolean): number {
return Math.max(0, free ? Math.round(tick) : snapDown(tick, grid));
return Math.max(0, free ? Math.round(tick) : snapNear(tick, grid));
}
private setStart(tick: number): void {
@@ -745,7 +793,7 @@ export class PianoRoll extends Widget {
else if (area === 'ruler' || area === 'grid' || area === 'lane')
view = { ...this.view, ...zoomTime(this.view, factor, g.x) };
else return;
this.view = clampRows(clampTimePan(view), this.gridHeight);
this.view = clampRows(clampTimePan(view, this.gridWidth), this.gridHeight);
this.needsFit = false;
this.redraw();
e.preventDefault();
@@ -758,6 +806,7 @@ export class PianoRoll extends Widget {
const g = this.gesture;
if (g?.kind === 'move' && g.moved) return shifted(asset.notes, this.picked, g.ticks, g.notes);
if (g?.kind === 'resize') return resized(asset.notes, this.picked, g.by);
if (g?.kind === 'trim') return trimmed(asset.notes, this.picked, g.by);
if (g?.kind === 'velocity') return withVelocity(asset.notes, this.picked, g.anchor, g.value);
if (g?.kind === 'erase') return asset.notes.filter((n) => !g.erased.has(n.id));
return asset.notes;
@@ -898,6 +947,11 @@ export class PianoRoll extends Widget {
.rect(Math.max(0, end), 0, gw - Math.max(0, end), VELOCITY_HEIGHT)
.fill({ color: 0x000000, alpha: 0.25 });
}
const zero = Math.min(gw, tickToX(this.view, 0));
if (zero > 0) {
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 });
}
}
private drawNotes(notes: readonly PatternNote[], length: number, gw: number, gh: number): void {
@@ -912,9 +966,22 @@ export class PianoRoll extends Widget {
.rect(box.x, box.y + 1, w, Math.max(1, box.h - 1))
.fill({ color: picked ? mix(HARD, 0xffffff, 0.35) : mix(SOFT, HARD, note.velocity), alpha })
.stroke({ width: 1, color: picked ? c.accentText : c.viewport, alpha, alignment: 1 });
if (this.hover?.id === note.id && this.hover.part !== 'body')
this.drawGrip({ ...box, w: Math.max(4, box.w) }, this.hover.part);
}
}
/** The end of a note the pointer would drag, lit, as on an item of the Song tab. */
private drawGrip(box: NoteBox, part: 'start' | 'end'): void {
const reach = Math.min(END_GRIP, box.w / 3);
const y = box.y + 1;
const h = Math.max(1, box.h - 1);
this.gridG
.rect(part === 'end' ? box.x + box.w - reach : box.x, y, reach, h)
.fill({ color: 0xffffff, alpha: 0.35 });
this.gridG.rect(part === 'end' ? box.x + box.w - 2 : box.x, y, 2, h).fill(Theme.colors.accentText);
}
private drawLane(notes: readonly PatternNote[], length: number, gw: number): void {
const c = Theme.colors;
this.laneG.rect(0, VELOCITY_HEIGHT / 2, gw, 1).fill({ color: c.border, alpha: 0.5 });
+26
View File
@@ -95,6 +95,32 @@ export function resized(
return notes.map((n) => (picked.has(n.id) ? { ...n, length: Math.max(1, n.length + by) } : n));
}
/**
* Where the grabbed note starts when its start is dragged to a tick: on the grid line nearest the
* pointer, its end staying put. Never up to its end – then on the last grid line before it – and
* never before tick 0.
*/
export function draggedStart(anchor: PatternNote, pointerTick: number, grid: number, free: boolean): number {
const end = anchor.tick + anchor.length;
if (free) return clamp(Math.round(pointerTick), 0, end - 1);
const start = snapNear(pointerTick, grid);
return Math.max(0, start < end ? start : snapDown(end - 1, grid));
}
/** Every picked note's start moved by what the grabbed one's was, its end kept, none shorter than a tick. */
export function trimmed(
notes: readonly PatternNote[],
picked: ReadonlySet<string>,
by: number,
): PatternNote[] {
return notes.map((n) => {
if (!picked.has(n.id)) return n;
const end = n.tick + n.length;
const tick = clamp(n.tick + by, 0, end - 1);
return { ...n, tick, length: end - tick };
});
}
/** How far right a copy of the picked notes goes so it stands right behind them: whole grid steps. */
export function duplicateOffset(
notes: readonly PatternNote[],
+8 -5
View File
@@ -19,8 +19,8 @@ export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1;
export const MIN_ROW_HEIGHT = 6;
export const MAX_ROW_HEIGHT = 28;
export const DEFAULT_ROW_HEIGHT = 14;
/** How far in from a note's right end a press takes its length rather than the note. */
const END_GRIP = 6;
/** How far in from an end of a note a press takes that end rather than the note. */
export const END_GRIP = 6;
/** A note narrower than this is still this wide to the pointer. */
const MIN_HIT_WIDTH = 4;
/** How close to a bar of the velocity lane a press must be to take it. */
@@ -73,10 +73,12 @@ export function noteBox(view: RollView, note: Pick<PatternNote, 'tick' | 'length
export interface NoteHit {
id: string;
/** The body moves the note, the end changes its length. */
part: 'body' | 'end';
/** The body moves the note, an end moves that end and so changes its length. */
part: NotePart;
}
export type NotePart = 'start' | 'body' | 'end';
/** The note under a point; where notes lie over each other, the one drawn last. */
export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec2): NoteHit | null {
for (let i = notes.length - 1; i >= 0; i--) {
@@ -85,7 +87,8 @@ export function noteAt(view: RollView, notes: readonly PatternNote[], point: Vec
const w = Math.max(box.w, MIN_HIT_WIDTH);
if (point.x < box.x || point.x >= box.x + w || point.y < box.y || point.y >= box.y + box.h) continue;
const grip = Math.min(END_GRIP, w / 3);
return { id: note.id, part: point.x >= box.x + w - grip ? 'end' : 'body' };
const part = point.x >= box.x + w - grip ? 'end' : point.x < box.x + grip ? 'start' : 'body';
return { id: note.id, part };
}
return null;
}