diff --git a/src/editor/debug/soundHooks.ts b/src/editor/debug/soundHooks.ts index 85391802..33399ca1 100644 --- a/src/editor/debug/soundHooks.ts +++ b/src/editor/debug/soundHooks.ts @@ -41,6 +41,8 @@ export function soundHooks({ state, editor }: HookContext) { stripAt: (seconds: number) => soundEditor.canvas.stripAt(seconds), /** The clips drawn faint over the wave, by name: all but the picked one. */ faintClips: () => soundEditor.canvas.faintClips(), + /** Wave draws, its shift from where it was drawn, and ruler labels given a text (#360). */ + drawInfo: () => soundEditor.canvas.drawInfo(), fadeInField: () => ({ ...centreOf(soundEditor.fadeInField), text: soundEditor.fadeInField.value }), fadeOutField: () => ({ ...centreOf(soundEditor.fadeOutField), text: soundEditor.fadeOutField.value }), /** The curve dropdowns show a picture of their curve; the tooltip names it. */ diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index 43ffee08..03718913 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -18,8 +18,10 @@ import { clampWave, type ClipBand, clipAt, + coversShown, type CutSpan, draggedSpan, + drawnSeconds, edgeAt, fileReach, fitWave, @@ -28,6 +30,7 @@ import { rulerLabel, rulerStep, secondsToX, + shownSeconds, spanOf, spanPeak, storedCut, @@ -63,6 +66,8 @@ const LIT_LABEL = textStyle('small').clone(); LIT_LABEL.fill = 0x30343c; /** Line pieces a fade's curve is drawn with at most. */ const RAMP_STEPS = 32; +/** The share of the area's width the wave is drawn past either side of it, for a pan to move (#360). */ +const DRAWN_MARGIN = 0.5; /** * A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip. @@ -116,6 +121,18 @@ export class WaveCanvas extends Widget { private readonly scaleLabels = new Container(); private readonly scaleTexts: Text[] = []; private readonly waveG = new Graphics(); + /** What `waveG` was drawn for: a pan within `seconds` only moves it. */ + private drawn: { + wave: Wave; + panX: number; + pxPerSecond: number; + level: number; + w: number; + h: number; + seconds: CutSpan | null; + } | null = null; + private waveDraws = 0; + private rulerTexts = 0; private readonly overlay = new Graphics(); private readonly labels = new Container(); private readonly stripLabels = new Container(); @@ -470,7 +487,6 @@ export class WaveCanvas extends Widget { this.bg.rect(SCALE_WIDTH - 1, RULER_HEIGHT, 1, STRIP_HEIGHT + wh).fill(c.border); this.clip.clear().rect(SCALE_WIDTH, 0, w, h).fill(0xffffff); this.scaleG.clear(); - this.waveG.clear(); this.overlay.clear(); const asset = this.asset(); this.hint.visible = !wave; @@ -483,24 +499,21 @@ export class WaveCanvas extends Widget { : 'Loading…'; this.hint.position.set(SCALE_WIDTH + Theme.padding * 2, WAVE_TOP + Theme.padding * 2); if (!wave || !asset) { + this.waveG.clear(); + this.drawn = null; this.syncLabels([]); this.syncScale([]); this.syncStrip([]); return; } - const x0 = secondsToX(this.view, 0); - const x1 = secondsToX(this.view, wave.duration); const mid = WAVE_TOP + wh / 2; const half = this.level * this.fullHalf; const room = wh / 2; - // The file itself a shade lighter than what lies around it, as high as the wave is zoomed out. const reach = fileReach(this.level, this.fullHalf, WAVE_INSET, room); - const left = Math.max(0, x0); - const right = Math.min(w, x1); - if (right > left) - this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 }); - if (right > left) this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border }); - this.drawWave(wave, left, right, mid, half, room); + if (!this.waveStillDrawn(wave)) this.drawFile(wave, mid, half, room, reach); + this.waveG.x = this.view.panX - this.drawn!.panX; + const left = Math.max(0, secondsToX(this.view, 0)); + const right = Math.min(w, secondsToX(this.view, wave.duration)); this.drawRuler(left, right); this.drawStrip(asset, wave); const span = this.shownClipSpan(); @@ -513,6 +526,48 @@ export class WaveCanvas extends Widget { } } + /** Whether the wave drawn last still serves: same file, zoom and size, and what is shown inside it. */ + private waveStillDrawn(wave: Wave): boolean { + const d = this.drawn; + return ( + d !== null && + d.wave === wave && + d.pxPerSecond === this.view.pxPerSecond && + d.level === this.level && + d.w === this.areaWidth && + d.h === this.h && + coversShown(d.seconds, shownSeconds(this.view, wave.duration, this.areaWidth)) + ); + } + + /** + * The file a shade lighter than what lies around it, as high as the wave is zoomed out, its middle + * line and the wave – past the area by a margin either side, so a pan only moves them (#360). + */ + private drawFile(wave: Wave, mid: number, half: number, room: number, reach: number): void { + const c = Theme.colors; + const w = this.areaWidth; + const seconds = drawnSeconds(this.view, wave.duration, w, w * DRAWN_MARGIN); + const { panX, pxPerSecond } = this.view; + this.drawn = { wave, panX, pxPerSecond, level: this.level, w, h: this.h, seconds }; + this.waveDraws++; + this.waveG.clear(); + if (!seconds) return; + const left = secondsToX(this.view, seconds.start); + const right = secondsToX(this.view, seconds.end); + this.waveG.rect(left, mid - reach, right - left, 2 * reach).fill({ color: c.panel, alpha: 0.5 }); + this.waveG.rect(left, mid, right - left, 1).fill({ color: c.border }); + this.drawWave(wave, left, right, mid, half, room); + } + + /** + * How often the wave has been drawn anew, how far it is moved from where it was drawn, and how often + * a ruler label was given a new text (test hook). + */ + drawInfo(): { waveDraws: number; shift: number; rulerTexts: number } { + return { waveDraws: this.waveDraws, shift: this.waveG.x, rulerTexts: this.rulerTexts }; + } + /** * A bar from the lowest to the highest sample per pixel, or the samples themselves zoomed far in. * `half` is full scale, what lies beyond `room` stops at the edge of the area. @@ -544,8 +599,10 @@ export class WaveCanvas extends Widget { const { min, max } = spanPeak(wave.channels, wave.peaks, from, to); const top = yOf(max); const bottom = yOf(min); - this.waveG.rect(x, top, 1, Math.max(1, bottom - top)).fill(color); + this.waveG.rect(x, top, 1, Math.max(1, bottom - top)); } + // One fill for all the bars: a fill each costs more than the bar itself. + this.waveG.fill(color); } private drawRuler(left: number, right: number): void { @@ -712,7 +769,8 @@ export class WaveCanvas extends Widget { this.stripTexts.push(text); this.stripLabels.addChild(text); } - text.style = label.lit ? LIT_LABEL : textStyle('small'); + const style = label.lit ? LIT_LABEL : textStyle('small'); + if (text.style !== style) text.style = style; if (text.text !== label.text) text.text = label.text; for (let cut = label.text.length; text.width > label.room && cut > 1; cut--) text.text = `${label.text.slice(0, cut - 1)}…`; @@ -722,18 +780,28 @@ export class WaveCanvas extends Widget { for (let i = placed.length; i < this.stripTexts.length; i++) this.stripTexts[i]!.visible = false; } + /** + * The ruler's labels. A label already showing a second keeps it: a pan shifts which one is first, + * and a text set anew is drawn anew (#360). + */ private syncLabels(placed: readonly { text: string; x: number }[]): void { + const shown = new Map(this.texts.filter((t) => t.visible).map((t) => [t.text, t])); + const kept = placed.map((label) => shown.get(label.text)); + const spare = this.texts.filter((t) => !kept.includes(t)); + for (const text of spare) text.visible = false; placed.forEach((label, i) => { - let text = this.texts[i]; + let text = kept[i] ?? spare.pop(); if (!text) { text = crispText({ style: textStyle('small') }); this.texts.push(text); this.labels.addChild(text); } text.visible = true; - if (text.text !== label.text) text.text = label.text; + if (text.text !== label.text) { + text.text = label.text; + this.rulerTexts++; + } text.position.set(label.x, 6); }); - for (let i = placed.length; i < this.texts.length; i++) this.texts[i]!.visible = false; } } diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index 7f1edbdb..302bfc8d 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -49,6 +49,32 @@ export function clampWave(view: WaveView, duration: number, width: number): Wave return { ...view, panX: clamp(view.panX, least, lead) }; } +/** The seconds of the file the area shows, held inside the file; null where none of it is in view. */ +export function shownSeconds(view: WaveView, duration: number, width: number): CutSpan | null { + const start = Math.max(0, xToSeconds(view, 0)); + const end = Math.min(duration, xToSeconds(view, width)); + return end > start ? { start, end } : null; +} + +/** + * The seconds a drawn wave covers: what the area shows and `margin` pixels either side, so a pan by + * less moves the drawing instead of drawing it anew (#360). + */ +export function drawnSeconds( + view: WaveView, + duration: number, + width: number, + margin: number, +): CutSpan | null { + return shownSeconds({ ...view, panX: view.panX + margin }, duration, width + 2 * margin); +} + +/** Whether a wave drawn over `drawn` still covers what is `shown`. */ +export function coversShown(drawn: CutSpan | null, shown: CutSpan | null): boolean { + if (!shown) return true; + return drawn !== null && drawn.start <= shown.start && drawn.end >= shown.end; +} + /** How far the wave's height zooms: out to a quarter, in to a sixteenth of full scale (#357). */ export const MIN_LEVEL = 0.25; export const MAX_LEVEL = 16; diff --git a/tests/editor/wave-view.test.ts b/tests/editor/wave-view.test.ts index f27da412..7e4a5e54 100644 --- a/tests/editor/wave-view.test.ts +++ b/tests/editor/wave-view.test.ts @@ -5,7 +5,9 @@ import { describe, expect, it } from 'vitest'; import { clampWave, + coversShown, draggedSpan, + drawnSeconds, edgeAt, fileReach, fitWave, @@ -15,6 +17,7 @@ import { rulerLabel, rulerStep, secondsToX, + shownSeconds, spanOf, spanPeak, storedCut, @@ -83,6 +86,23 @@ describe('the wave view', () => { expect(rulerLabel(0.25, 0.05)).toBe('0.25s'); expect(rulerLabel(75, 5)).toBe('1:15'); }); + + it('draws the wave past the area by a margin, and a pan within it keeps that drawing (#360)', () => { + // 20 s at 100 px/s, 1000 px across: seconds 5 to 15 shown, 0 to 20 with 500 px either side. + const view = { panX: -500, pxPerSecond: 100 }; + expect(shownSeconds(view, 20, 1000)).toEqual({ start: 5, end: 15 }); + const drawn = drawnSeconds(view, 20, 1000, 500); + expect(drawn).toEqual({ start: 0, end: 20 }); + const near = drawnSeconds({ ...view, panX: -1000 }, 20, 1000, 300); + expect(near).toEqual({ start: 7, end: 20 }); + // Panned 200 px it still covers what is shown, 400 px past it no longer. + expect(coversShown(near, shownSeconds({ ...view, panX: -800 }, 20, 1000))).toBe(true); + expect(coversShown(near, shownSeconds({ ...view, panX: -600 }, 20, 1000))).toBe(false); + // Nothing of the file in view: nothing to cover, and nothing drawn. + expect(shownSeconds({ panX: 1200, pxPerSecond: 100 }, 20, 1000)).toBeNull(); + expect(coversShown(null, null)).toBe(true); + expect(coversShown(null, { start: 0, end: 1 })).toBe(false); + }); }); describe('the cut span', () => { diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 82c6eba6..4f35e48c 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1980,6 +1980,35 @@ export async function sound(t) { Math.abs(refitted.top - fullTop) < 0.5, JSON.stringify({ levelScale, levelRect, fullTop, secondAt, zoomedOut, zoomedIn, refitted }), ); + // 34h-2b. A pan moves the wave drawn already and leaves each ruler label on its second (#360): + // drawing both anew on every move took the frames down. + const panFrom = await json('__gm.soundTab.at(0.375)'); + await t.wheel(panFrom.x, panFrom.y, -924); + const panInfo = async () => ({ + ...(await json('__gm.soundTab.drawInfo()')), + at: (await json('__gm.soundTab.at(0.375)')).x, + }); + const panBefore = await panInfo(); + await t.mouse('mouseMoved', panFrom.x, panFrom.y); + await t.mouse('mousePressed', panFrom.x, panFrom.y, 'middle'); + for (let i = 1; i <= 8; i++) { + await t.mouse('mouseMoved', panFrom.x - 30 * i, panFrom.y, 'middle', { buttons: 4 }); + await sleep(20); + } + await t.mouse('mouseReleased', panFrom.x - 240, panFrom.y, 'middle'); + await sleep(100); + const panAfter = await panInfo(); + const panMoved = panAfter.at - panBefore.at; + await click(levelFit.x, levelFit.y); + await sleep(100); + check( + 'a pan moves the wave drawn already, and the ruler labels keep their seconds', + Math.abs(panMoved + 240) < 1 && + panAfter.waveDraws === panBefore.waveDraws && + Math.abs(panAfter.shift - panBefore.shift - panMoved) < 0.5 && + panAfter.rulerTexts - panBefore.rulerTexts <= 2, + JSON.stringify({ panBefore, panAfter, panMoved }), + ); const clipProps = async () => (await clips())[0]?.props; let dragFrom = await json('__gm.soundTab.stripAt(0.1)'); let dragTo = await json('__gm.soundTab.stripAt(0.3)');