diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts index 6e8069e8..41c168ab 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -49,6 +49,19 @@ export function clampTimePan(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) }; diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 8ff4a9db..00b49007 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -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 } | { 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(); /** 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 { diff --git a/src/editor/pattern/rollEdit.ts b/src/editor/pattern/rollEdit.ts index 90c3e735..bf146a8d 100644 --- a/src/editor/pattern/rollEdit.ts +++ b/src/editor/pattern/rollEdit.ts @@ -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, + })); } /** diff --git a/src/editor/pattern/rollView.ts b/src/editor/pattern/rollView.ts index d9c07eb4..df331ebe 100644 --- a/src/editor/pattern/rollView.ts +++ b/src/editor/pattern/rollView.ts @@ -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 { - 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 }; } diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index f5f9c668..e85a4ea0 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -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, 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 { diff --git a/src/editor/song/songEdit.ts b/src/editor/song/songEdit.ts index 409203d7..93fd6b72 100644 --- a/src/editor/song/songEdit.ts +++ b/src/editor/song/songEdit.ts @@ -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, 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, })); } diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index 0940bf3e..e5e33f4e 100644 --- a/src/editor/song/songView.ts +++ b/src/editor/song/songView.ts @@ -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, 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(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 }; } diff --git a/tests/editor/piano-roll.test.ts b/tests/editor/piano-roll.test.ts index 7e007202..8f8b0faa 100644 --- a/tests/editor/piano-roll.test.ts +++ b/tests/editor/piano-roll.test.ts @@ -103,20 +103,23 @@ describe('rows of the piano roll (#333)', () => { expect(noteAtY({ ...VIEW, panY: 0 }, ROWS * 10)).toBeNull(); }); - it('keeps the rows on screen and zooms them about the pointer', () => { - expect(clampRows({ ...VIEW, panY: 50 }, 300).panY).toBe(0); - expect(clampRows({ ...VIEW, panY: -5000 }, 300).panY).toBe(300 - ROWS * 10); + 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(50); + 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); expect(zoomed.rowHeight).toBe(20); 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); - 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 }; 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(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'); }); + 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', () => { 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([ diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 801292d2..ef824d77 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { newItem, type SongItem } from '../../src/core/music/song'; +import { secondsOfTicks } from '../../src/core/music/time'; import { clampMove, draggedLength, @@ -8,6 +9,8 @@ import { moveDelta, moved, placedCopies, + roundsOf, + snapEdge, split, trimDelta, 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 }); }); + 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', () => { 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([ @@ -111,6 +148,9 @@ describe('moving and sizing items (#334)', () => { [480 + 2 * BAR, 't2'], ]); 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', () => { @@ -138,9 +178,12 @@ describe('where things are in the Song tab (#334)', () => { ]); }); - it('scrolls no further than the tracks', () => { - expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(0); - expect(clampTracks({ ...view, panY: -500 }, 10, 400).panY).toBe(400 - 10 * TRACK_HEIGHT); + it('scrolls half the height past the first or the last track at most', () => { + expect(clampTracks({ ...view, panY: 40 }, 3, 400).panY).toBe(40); + 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', () => { @@ -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(zoomTracks(view, 100, 0).trackHeight).toBe(MAX_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({ panX: -5, pxPerTick: 0.3, diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index 1c77f49d..58429565 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -201,6 +201,22 @@ export async function pattern(t) { free?.tick % 120 !== 0 && Math.abs(free.tick - 770) <= 6 && back?.tick === 720, 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. 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, 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. p = await json('__gm.pattern.gridAt(1980, 60)'); @@ -317,13 +351,41 @@ export async function pattern(t) { const countPanned = (await notes()).length; await click(zero.x - 20, zero.y); 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()'); await click(p.x, p.y); + const fitted = await json('__gm.pattern.view()'); 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', - Math.abs(panned.panX - (area.w - 84) / 2) < 1 && countLeft === countPanned, - JSON.stringify({ panned, area, countPanned, countLeft }), + '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(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)'); to = await json('__gm.pattern.loopAt(2880)'); await drag(p.x, p.y, to.x, to.y); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 5cfa9c3e..f5551de4 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -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 * three more. A pattern and a * 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 * 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 @@ -231,6 +232,40 @@ export async function song(t) { 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. p = await json(`__gm.song.itemAt(${JSON.stringify(riff.id)}, 'body')`); await click(p.x, p.y);