From d0b76ec35531098299b1d4fa59b238c7b538c771 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 07:33:47 +0200 Subject: [PATCH] Fit a song both ways and zoom its time in further (#362) Fit in the Song tab only set the time, from the very left edge, and kept the height the tracks had. The time also stopped zooming at the bound of the Pattern tab, too soon to lay a take down closely. - fitSong in songView.ts frames the song with 9 % of the lanes free on every side. The tracks share out the height in between, held to 28 to 320 px. A song opened without a kept view fits the same way. - The time zooms in to 8 px a tick, eight times the bound of the Pattern tab. zoomTime takes the bound as a parameter, so the Pattern tab keeps its own. Tests: - Unit song-edit: 15 green (1 new), the counter-check without the margin is red. - e2e 65: ALL PASSED, 56 checks (1 new). The new check presses Fit after the tracks are added and finds room around bar and tracks. Adding a track presses Fit first now: a new song opens with its one track 320 px tall, so the track added below it lay off the edge. - Gate: 272 files, 4213 tests green. Co-Authored-By: Claude Opus 5.5 --- src/editor/music/timeView.ts | 6 +++--- src/editor/song/SongTimeline.ts | 14 ++++++-------- src/editor/song/songView.ts | 25 ++++++++++++++++++++++++- tests/editor/song-edit.test.ts | 21 +++++++++++++++++++++ tools/e2e/65-song.mjs | 27 ++++++++++++++++++++++++--- 5 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts index abdaec6c..2c8b0535 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -30,9 +30,9 @@ 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); +/** Zooms about `x`, so the tick under the pointer stays under it, up to `most` pixels a tick. */ +export function zoomTime(view: TimeView, factor: number, x: number, most = MAX_PX_PER_TICK): TimeView { + const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, most); const tick = xToTick(view, x); return { panX: x - tick * pxPerTick, pxPerTick }; } diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index bb379408..9fe7cab9 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -62,7 +62,6 @@ import { TimeRuler } from '../music/TimeRuler'; import { clampTimePan, draggedLoop, - fitTime, LOOP_STRIP, RULER_HEIGHT, rulerPartAt, @@ -105,6 +104,7 @@ import { HEAD_BOXES, HEAD_WIDTH, EDGE_REACH, + fitSong, type HeadPart, headPartAt, heldSongView, @@ -121,6 +121,7 @@ import { SLIDERS_MIN_HEIGHT, sliderShare, sliderValue, + SONG_MAX_PX_PER_TICK, type SongView, TRACK_HEIGHT, trackAtY, @@ -346,7 +347,7 @@ export class SongTimeline extends Widget { this.redraw(); } - /** The whole song across, from its first track down. */ + /** The whole song across and all its tracks down, with some room around. */ fit(): void { const asset = this.asset(); if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { @@ -354,11 +355,8 @@ export class SongTimeline extends Widget { return; } this.needsFit = false; - this.view = { - ...fitTime(songLength(asset), this.lanesWidth), - panY: 0, - trackHeight: this.view.trackHeight, - }; + const count = this.shownTracks(asset).length; + this.view = fitSong(songLength(asset), count, this.lanesWidth, this.lanesHeight); this.redraw(); } @@ -1266,7 +1264,7 @@ export class SongTimeline extends Widget { let view: SongView; if (area === 'heads') view = zoomTracks(this.view, factor, g.y); else if (area === 'ruler' || area === 'lanes') - view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; + view = { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) }; else return; this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); this.needsFit = false; diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index cc3887e7..a30d7fae 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, clampRowPan, tickToX, xToTick } from '../music/timeView'; +import { MIN_PX_PER_TICK, 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 @@ -57,6 +57,29 @@ export function nearestTrack(view: SongView, count: number, y: number): number { return clamp(Math.floor((y - view.panY) / view.trackHeight), 0, Math.max(0, count - 1)); } +/** + * The song zooms in eight times as far as a pattern: a quarter is then 3840 pixels wide, so a take + * can be laid down to the millisecond. + */ +export const SONG_MAX_PX_PER_TICK = 8; +/** What Fit leaves free on each side of the song, as a share of the lanes' width and height. */ +export const FIT_MARGIN = 0.09; + +/** + * The whole song in the lanes with some room around it: its length across and its tracks down, each + * as tall as they share out, within the heights a track may have. + */ +export function fitSong(length: number, count: number, width: number, height: number): SongView { + const side = width * FIT_MARGIN; + const top = height * FIT_MARGIN; + return { + panX: side, + pxPerTick: clamp((width - 2 * side) / Math.max(1, length), MIN_PX_PER_TICK, SONG_MAX_PX_PER_TICK), + panY: top, + trackHeight: clamp((height - 2 * top) / Math.max(1, count), MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT), + }; +} + /** Zooms the tracks about `y`, so the track under the pointer stays under it. */ export function zoomTracks(view: SongView, factor: number, y: number): SongView { const trackHeight = clamp(view.trackHeight * factor, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT); diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 3c7cd673..3ab535ec 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -17,6 +17,8 @@ import { } from '../../src/editor/song/songEdit'; import { clampTracks, + FIT_MARGIN, + fitSong, HEAD_BOXES, headPartAt, itemAt, @@ -28,11 +30,13 @@ import { peakOver, SLIDERS_MIN_HEIGHT, sliderValue, + SONG_MAX_PX_PER_TICK, type SongView, TRACK_HEIGHT, trackAtY, zoomTracks, } from '../../src/editor/song/songView'; +import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeView'; const BAR = 1920; const TRACKS = ['t1', 't2', 't3']; @@ -222,6 +226,23 @@ describe('where things are in the Song tab (#334)', () => { }); }); + it('fits the song across and its tracks down with room around, and zooms in further than a pattern', () => { + // 4 bars and 4 tracks in 1000 x 500: 90 pixels free left and right, 45 above and below. + const fitted = fitSong(4 * BAR, 4, 1000, 500); + expect(fitted.panX).toBeCloseTo(1000 * FIT_MARGIN); + expect(fitted.panY).toBeCloseTo(500 * FIT_MARGIN); + expect(tickToX(fitted, 4 * BAR)).toBeCloseTo(1000 * (1 - FIT_MARGIN)); + expect(fitted.panY + 4 * fitted.trackHeight).toBeCloseTo(500 * (1 - FIT_MARGIN)); + // A track keeps the heights it may have, however many or few there are. + expect(fitSong(BAR, 1, 1000, 2000).trackHeight).toBe(MAX_TRACK_HEIGHT); + expect(fitSong(BAR, 60, 1000, 500).trackHeight).toBe(MIN_TRACK_HEIGHT); + // The wheel goes past where a pattern stops, up to the song's own bound. + const deep = zoomTime({ panX: 0, pxPerTick: 1 }, 100, 0, SONG_MAX_PX_PER_TICK); + expect(SONG_MAX_PX_PER_TICK).toBeGreaterThan(MAX_PX_PER_TICK); + expect(deep.pxPerTick).toBe(SONG_MAX_PX_PER_TICK); + expect(fitSong(1, 1, 1000, 500).pxPerTick).toBe(SONG_MAX_PX_PER_TICK); + }); + it('marks where an item starts over inside it, from as far in as it starts', () => { expect(loopSeams({ tick: BAR, length: 3 * BAR }, BAR, 0)).toEqual([2 * BAR, 3 * BAR]); expect(loopSeams({ tick: BAR, length: 2 * BAR }, BAR, 480)).toEqual([2 * BAR - 480, 3 * BAR - 480]); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index e7f13281..5cd1ed4f 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -133,8 +133,13 @@ export async function song(t) { ); const id = created.songs[0]?.id; const model = () => json(`__gm.song.model(${JSON.stringify(id)})`); - /** Adds a track under the last one through its head's menu: there is no other way to. */ + /** + * Adds a track under the last one through its head's menu: there is no other way to. Fit first: + * the tracks a fit shares the height out to leave the one added last below the edge. + */ const addTrackBelowLast = async () => { + const fit = await json('__gm.song.fitAt()'); + await click(fit.x, fit.y); const count = (await model()).tracks.length; const head = await json(`__gm.song.headAt(${count - 1}, 'name')`); await click(head.x, head.y, 'right'); @@ -145,10 +150,26 @@ export async function song(t) { for (let i = 0; i < 3; i++) await addTrackBelowLast(); const tracks = (await model()).tracks.map((track) => track.id); - // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. - // A pattern dragged onto the second track lands on the bar before the pointer, a bar long. + // 65a-ii. Fit takes the song across and its four tracks down, with some room on every side: the + // bar starts right of the heads and ends left of the edge, the last track ends above it. + const fitButton = await json('__gm.song.fitAt()'); + await click(fitButton.x, fitButton.y); const fitted = await json('__gm.song.view()'); const area = await json('__gm.song.rect()'); + const fitEnds = await json(`[__gm.song.laneAt(0, 0), __gm.song.laneAt(${BAR}, 3)]`); + const bottom = fitEnds[1].y + fitted.trackHeight / 2; + check( + 'Fit leaves room around the song: across the bar and down all four tracks', + fitted.panX > 0 && + fitted.panY > 0 && + fitEnds[1].x < area.x + area.w - 20 && + bottom < area.y + area.h - 20 && + bottom > area.y + area.h * 0.8, + JSON.stringify({ fitted, area, fitEnds }), + ); + + // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. + // A pattern dragged onto the second track lands on the bar before the pointer, a bar long. for (let i = 0; i < 30; i++) { const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`); if (far.x < area.x + area.w) break;