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>
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
import { type Meter, ticksPerBar, ticksPerBeat } from '../../core/music/time';
|
|
import { clamp } from '../../ui';
|
|
|
|
/**
|
|
* The time axis of the piano roll (#333), and of the song tab after it (#334), apart from Pixi so a
|
|
* test can drive it: ticks run left to right, `x` is measured from the left edge of the area that
|
|
* shows them. Only across; what is stacked above each other is up to the tab.
|
|
*/
|
|
export interface TimeView {
|
|
/** Pixels from the area's left edge to tick 0. */
|
|
panX: number;
|
|
pxPerTick: number;
|
|
}
|
|
|
|
export const RULER_HEIGHT = 30;
|
|
/** The strip along the top of the ruler that holds the loop; a drag there sets it, below moves the start. */
|
|
export const LOOP_STRIP = 10;
|
|
/** A bar of 4/4 from 8 pixels to a 1/32 of 60. */
|
|
export const MIN_PX_PER_TICK = 0.004;
|
|
export const MAX_PX_PER_TICK = 1;
|
|
const BAR_LABEL_MIN_PX = 36;
|
|
const BEAT_MIN_PX = 6;
|
|
const GRID_MIN_PX = 5;
|
|
|
|
export function tickToX(view: TimeView, tick: number): number {
|
|
return view.panX + tick * view.pxPerTick;
|
|
}
|
|
|
|
export function xToTick(view: TimeView, x: number): number {
|
|
return (x - view.panX) / view.pxPerTick;
|
|
}
|
|
|
|
/** Zooms about `x`, so the tick under the pointer stays under it. */
|
|
export function zoomTime(view: TimeView, factor: number, x: number): TimeView {
|
|
const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, MAX_PX_PER_TICK);
|
|
const tick = xToTick(view, x);
|
|
return { panX: x - tick * pxPerTick, pxPerTick };
|
|
}
|
|
|
|
/** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */
|
|
export const TIME_LEAD = 0.5;
|
|
|
|
/**
|
|
* Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in,
|
|
* to look at, with nothing to place left of it.
|
|
*/
|
|
export function clampTimePan<V extends TimeView>(view: V, width: number): V {
|
|
const most = Math.max(0, width * TIME_LEAD);
|
|
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) };
|
|
}
|
|
|
|
/** A line across the time: 0 a bar, 1 a beat, 2 a step of the grid. */
|
|
export interface TimeLine {
|
|
tick: number;
|
|
x: number;
|
|
level: 0 | 1 | 2;
|
|
}
|
|
|
|
/**
|
|
* The lines from `0` to `width` pixels, each tick only at its strongest level. Beats and the grid
|
|
* are left out where they would stand closer than a few pixels.
|
|
*/
|
|
export function timeLines(view: TimeView, meter: Meter, grid: number, width: number): TimeLine[] {
|
|
const levels = new Map<number, 0 | 1 | 2>();
|
|
const from = Math.max(0, xToTick(view, 0));
|
|
const to = xToTick(view, width);
|
|
const spans: [number, 0 | 1 | 2, number][] = [
|
|
[ticksPerBar(meter), 0, 0],
|
|
[ticksPerBeat(meter), 1, BEAT_MIN_PX],
|
|
[grid, 2, GRID_MIN_PX],
|
|
];
|
|
for (const [step, level, minPx] of spans) {
|
|
if (step * view.pxPerTick < minPx) continue;
|
|
for (let tick = Math.ceil(from / step) * step; tick <= to; tick += step)
|
|
if (!levels.has(tick)) levels.set(tick, level);
|
|
}
|
|
return [...levels]
|
|
.map(([tick, level]) => ({ tick, x: tickToX(view, tick), level }))
|
|
.sort((a, b) => a.tick - b.tick);
|
|
}
|
|
|
|
export type RulerPart = 'loop' | 'time';
|
|
|
|
export function rulerPartAt(y: number): RulerPart {
|
|
return y < LOOP_STRIP ? 'loop' : 'time';
|
|
}
|
|
|
|
/**
|
|
* The loop a drag on the loop strip leaves: from where it was pressed to `at`, or, pressed at an end
|
|
* of `loop`, that end moved to `at`. Null where the drag spans nothing.
|
|
*/
|
|
export function draggedLoop(
|
|
loop: { start: number; end: number },
|
|
edge: 'start' | 'end' | null,
|
|
from: number,
|
|
at: number,
|
|
): { start: number; end: number } | null {
|
|
if (edge === 'start') return { start: Math.min(at, loop.end - 1), end: loop.end };
|
|
if (edge === 'end') return { start: loop.start, end: Math.max(at, loop.start + 1) };
|
|
return at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) };
|
|
}
|
|
|
|
/** Every `n`-th bar gets its number on the ruler, `n` a power of two so the numbers never overlap. */
|
|
export function barLabelStep(view: TimeView, meter: Meter): number {
|
|
const barPx = ticksPerBar(meter) * view.pxPerTick;
|
|
let n = 1;
|
|
while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2;
|
|
return n;
|
|
}
|