Round 12 of the clean-up. Duplicate code had grown from 33 clones (249 lines) after round 11 to 140 clones (1263 lines), almost all of it in the panels and faces of the sound FX devices (#379 to #401). It is down to 57 clones (416 lines) now, and src is 816 lines shorter. - deviceParts.ts holds what the device panels built by hand: the head, rows of knobs, the screen style and the row layout for groups of knobs. Twelve panels use it. panel.knobs stays because the debug hooks read it. - DeviceFace is the base of the twelve faces. It finds the node and does wired, preview, commit, set, hide, reload and audition. compressorWired lives there as paramWired. - TextPool replaces ten copies of "make texts as needed, hide the rest" in the wave and video tabs, the rulers, the piano roll and the song and composition timelines. clipStrip.ts is the clip label strip the wave and video tabs share. - core/util/bytes.ts has the concat that ogg, matroska and avcColour each wrote out. - Dead code is gone: clear on the video players (never called since #402), posterFileOf and videoTimeAt (only tests called them) and easeOut (the same as glide(t, 3)). Values only used in their own file lose their export. - Comments over 125 columns are wrapped, and comments that told the history ("as before", "used to") say what is, in src and in the e2e modules. - Left alone on purpose: AnimationEditor and CompositionEditor share only the play button, and the curve panels are built differently. - Chip now places its boxes through put, which rounds. They were whole numbers already. The three row layouts are byte-identical before and after. - README lists the video and composition assets and the Videos and Compositions pages. Tests: - Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests. - e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks. - TextPool without keeping its texts and framesToRoom without making room both go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/** The zoom transition (#309): the way between two views, the driver that shows it, and its setting. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
generalSettings,
|
|
keepGeneralSettings,
|
|
onGeneralSettings,
|
|
resetGeneralSettings,
|
|
zoomTransitionMs,
|
|
} from '../../src/editor/generalSettings';
|
|
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
|
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
|
|
import {
|
|
axisTween,
|
|
carriedSpeed,
|
|
glide,
|
|
glideSlope,
|
|
scrollTween,
|
|
tweenView,
|
|
type ViewMap,
|
|
ViewTween,
|
|
} from '../../src/editor/viewTween';
|
|
import { ScrollContainer, Spacer, VStack } from '../../src/ui';
|
|
|
|
function fakeClock() {
|
|
let time = 0;
|
|
const ticks = new Set<() => void>();
|
|
return {
|
|
now: () => time,
|
|
add: (fn: () => void) => void ticks.add(fn),
|
|
remove: (fn: () => void) => void ticks.delete(fn),
|
|
advance(ms: number) {
|
|
time += ms;
|
|
for (const fn of [...ticks]) fn();
|
|
},
|
|
get attached() {
|
|
return ticks.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A surface with a view and nothing else; `draws` counts what it was asked to draw. */
|
|
function surface(start: ViewMap = { scale: 1, x: 0, y: 0 }) {
|
|
const s = { view: { ...start }, draws: 0 };
|
|
const target = {
|
|
read: () => ({ ...s.view }),
|
|
write: (map: ViewMap) => void (s.view = { ...map }),
|
|
draw: () => void (s.draws += 1),
|
|
};
|
|
return { s, target };
|
|
}
|
|
|
|
/** The world point shown at a screen point. */
|
|
const worldAt = (view: ViewMap, x: number, y: number) => ({
|
|
x: (x - view.x) / view.scale,
|
|
y: (y - view.y) / view.scale,
|
|
});
|
|
|
|
describe('the way between two views', () => {
|
|
it('starts on the first and ends on the second', () => {
|
|
const from = { scale: 1, x: 10, y: 20 };
|
|
const to = { scale: 3, x: -40, y: 5 };
|
|
expect(tweenView(from, to, 0)).toEqual(from);
|
|
expect(tweenView(from, to, 1)).toEqual(to);
|
|
});
|
|
|
|
it('keeps the graph point under the pointer under it all the way', () => {
|
|
const from = { pan: { x: 120, y: 80 }, zoom: 1 };
|
|
const to = zoomedAbout(from, { x: 300, y: 200 }, 2);
|
|
const a = { scale: from.zoom, x: from.pan.x, y: from.pan.y };
|
|
const b = { scale: to.zoom, x: to.pan.x, y: to.pan.y };
|
|
for (const t of [0.1, 0.5, 0.9]) {
|
|
const mid = worldAt(tweenView(a, b, t), 300, 200);
|
|
expect(mid.x).toBeCloseTo(180, 9);
|
|
expect(mid.y).toBeCloseTo(120, 9);
|
|
}
|
|
});
|
|
|
|
it('keeps the picture pixel under the pointer under it in the sheet tabs', () => {
|
|
const from = { panX: 40, panY: -12, zoom: 4 };
|
|
const to = zoomAbout(from, 0.5, 250, 90);
|
|
const mid = tweenView(
|
|
{ scale: from.zoom, x: from.panX, y: from.panY },
|
|
{ scale: to.zoom, x: to.panX, y: to.panY },
|
|
0.4,
|
|
);
|
|
const pixel = localToImage({ panX: mid.x, panY: mid.y, zoom: mid.scale }, 250, 90);
|
|
const before = localToImage(from, 250, 90);
|
|
expect(pixel.x).toBeCloseTo(before.x, 9);
|
|
expect(pixel.y).toBeCloseTo(before.y, 9);
|
|
});
|
|
|
|
it('moves the scale by equal ratios, so halfway from 1 to 4 is 2', () => {
|
|
expect(tweenView({ scale: 1, x: 0, y: 0 }, { scale: 4, x: 0, y: 0 }, 0.5).scale).toBeCloseTo(2, 12);
|
|
});
|
|
|
|
it('slides between two views of the same scale', () => {
|
|
expect(tweenView({ scale: 2, x: 0, y: 10 }, { scale: 2, x: 100, y: 30 }, 0.25)).toEqual({
|
|
scale: 2,
|
|
x: 25,
|
|
y: 15,
|
|
});
|
|
});
|
|
|
|
it('eases out at 3: most of the way early, nothing past the end', () => {
|
|
expect(glide(0, 3)).toBe(0);
|
|
expect(glide(1, 3)).toBe(1);
|
|
expect(glide(2, 3)).toBe(1);
|
|
expect(glide(0.5, 3)).toBeCloseTo(0.875, 12);
|
|
});
|
|
});
|
|
|
|
describe('the transition of a view', () => {
|
|
const double = (s: { view: ViewMap }) => () => {
|
|
s.view = { scale: s.view.scale * 2, x: s.view.x * 2, y: s.view.y * 2 };
|
|
};
|
|
|
|
it('shows the way from what was on screen to the new view, and lets go at the end', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
expect(s.view).toEqual({ scale: 1, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(true);
|
|
expect(tween.heading).toEqual({ scale: 2, x: 0, y: 0 });
|
|
clock.advance(100);
|
|
expect(s.view.scale).toBeGreaterThan(1);
|
|
expect(s.view.scale).toBeLessThan(2);
|
|
clock.advance(100);
|
|
expect(s.view).toEqual({ scale: 2, x: 0, y: 0 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('adds quick steps up to exactly as many steps, starting the way from what is shown', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const shown = { ...s.view };
|
|
tween.change(double(s));
|
|
expect(tween.heading?.scale).toBe(4);
|
|
expect(s.view).toEqual(shown);
|
|
clock.advance(200);
|
|
expect(s.view.scale).toBe(4);
|
|
});
|
|
|
|
it('keeps its pace when a wheel event makes no step', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
tween.change(() => {});
|
|
clock.advance(150);
|
|
expect(tween.moving).toBe(false);
|
|
expect(s.view.scale).toBe(2);
|
|
});
|
|
|
|
it('lets go when something else moves the view', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
s.view = { scale: 7, x: 1, y: 2 };
|
|
clock.advance(16);
|
|
expect(s.view).toEqual({ scale: 7, x: 1, y: 2 });
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('jumps at once at 0 ms', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 0, clock);
|
|
tween.change(double(s));
|
|
expect(s.view.scale).toBe(2);
|
|
expect(tween.moving).toBe(false);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
it('lands on the target without drawing', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(50);
|
|
const draws = s.draws;
|
|
tween.land();
|
|
expect(s.view.scale).toBe(2);
|
|
expect(s.draws).toBe(draws);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
|
|
/** How fast the scale moves, in ratio per ms, over the next `ms`. */
|
|
const pace = (s: { view: ViewMap }, clock: ReturnType<typeof fakeClock>, ms: number) => {
|
|
const before = s.view.scale;
|
|
clock.advance(ms);
|
|
return Math.log(s.view.scale / before) / ms;
|
|
};
|
|
|
|
it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(48);
|
|
const before = pace(s, clock, 2);
|
|
tween.change(double(s));
|
|
const after = pace(s, clock, 2);
|
|
expect(after / before).toBeGreaterThan(0.9);
|
|
expect(after / before).toBeLessThan(1.1);
|
|
clock.advance(200);
|
|
expect(s.view.scale).toBe(4);
|
|
});
|
|
|
|
it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
|
|
const clock = fakeClock();
|
|
const { s, target } = surface();
|
|
const tween = new ViewTween(target, () => 200, clock);
|
|
tween.change(double(s));
|
|
clock.advance(60);
|
|
let hold = { x: 0, y: 0 };
|
|
tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
|
|
expect(hold).toEqual({ x: 0, y: 0 });
|
|
const shown = { ...s.view };
|
|
tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
|
|
expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
|
|
expect(tween.moving).toBe(true);
|
|
clock.advance(16);
|
|
expect(tween.moving).toBe(true);
|
|
expect(s.view.scale).toBeGreaterThan(shown.scale);
|
|
clock.advance(200);
|
|
expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
|
|
});
|
|
});
|
|
|
|
describe('the curve of a transition', () => {
|
|
it('starts at the speed it is given and settles at the end', () => {
|
|
for (const speed of [0, 1.5, 3, 4]) {
|
|
expect(glide(0, speed)).toBe(0);
|
|
expect(glide(1, speed)).toBe(1);
|
|
expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
|
|
expect(glideSlope(1, speed)).toBe(0);
|
|
expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
|
|
}
|
|
});
|
|
|
|
it('never runs back or past its end up to the steepest start', () => {
|
|
for (const speed of [0, 2, 4]) {
|
|
let last = 0;
|
|
for (let i = 1; i <= 100; i++) {
|
|
const share = glide(i / 100, speed);
|
|
expect(share).toBeGreaterThanOrEqual(last);
|
|
expect(share).toBeLessThanOrEqual(1);
|
|
last = share;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
|
|
const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
|
|
const from = tweenView(old.from, old.to, 0.5);
|
|
// 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
|
|
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
|
|
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('a view whose axes zoom apart (#377)', () => {
|
|
function song(clock: ReturnType<typeof fakeClock>) {
|
|
const s = { view: { panX: 40, pxPerTick: 0.02, panY: 10, trackHeight: 52 } };
|
|
const time = axisTween(
|
|
() => ({ scale: s.view.pxPerTick, offset: s.view.panX }),
|
|
({ scale, offset }) => (s.view = { ...s.view, pxPerTick: scale, panX: offset }),
|
|
() => {},
|
|
() => 200,
|
|
clock,
|
|
);
|
|
const tracks = axisTween(
|
|
() => ({ scale: s.view.trackHeight, offset: s.view.panY }),
|
|
({ scale, offset }) => (s.view = { ...s.view, trackHeight: scale, panY: offset }),
|
|
() => {},
|
|
() => 200,
|
|
clock,
|
|
);
|
|
return { s, time, tracks };
|
|
}
|
|
|
|
it('zooms the time about the pointer and leaves the tracks alone', () => {
|
|
const clock = fakeClock();
|
|
const { s, time, tracks } = song(clock);
|
|
const tickAt = (x: number) => (x - s.view.panX) / s.view.pxPerTick;
|
|
const held = tickAt(300);
|
|
time.change(() => (s.view = { ...s.view, pxPerTick: 0.08, panX: 300 - held * 0.08 }));
|
|
clock.advance(80);
|
|
expect(s.view.pxPerTick).toBeGreaterThan(0.02);
|
|
expect(s.view.pxPerTick).toBeLessThan(0.08);
|
|
expect(tickAt(300)).toBeCloseTo(held, 9);
|
|
expect(s.view.trackHeight).toBe(52);
|
|
expect(s.view.panY).toBe(10);
|
|
expect([time.moving, tracks.moving]).toEqual([true, false]);
|
|
clock.advance(200);
|
|
expect(s.view.pxPerTick).toBe(0.08);
|
|
expect(time.moving).toBe(false);
|
|
});
|
|
|
|
it('moves both axes on a Fit, each landing where the jump would', () => {
|
|
const clock = fakeClock();
|
|
const { s, time, tracks } = song(clock);
|
|
const fitted = { panX: 30, pxPerTick: 0.005, panY: 20, trackHeight: 90 };
|
|
time.change(() => tracks.change(() => (s.view = { ...fitted })));
|
|
clock.advance(100);
|
|
expect(s.view.pxPerTick).toBeLessThan(0.02);
|
|
expect(s.view.trackHeight).toBeGreaterThan(52);
|
|
expect([time.moving, tracks.moving]).toEqual([true, true]);
|
|
clock.advance(200);
|
|
expect(s.view).toEqual(fitted);
|
|
expect(clock.attached).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('a list scrolled by the wheel', () => {
|
|
function list(clock: ReturnType<typeof fakeClock>) {
|
|
const scroll = new ScrollContainer();
|
|
scroll.resize(200, 100);
|
|
const rows = new VStack();
|
|
for (let i = 0; i < 20; i++) rows.add(new Spacer(20));
|
|
scroll.setContent(rows);
|
|
const tween = scrollTween(scroll, () => 200, clock);
|
|
const wheel = (deltaY: number) => scroll.wheelStep(() => scroll.scrollTo(scroll.offset + deltaY));
|
|
return { scroll, tween, wheel };
|
|
}
|
|
|
|
it('glides to where the step leads and lands there', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, tween, wheel } = list(clock);
|
|
wheel(100);
|
|
expect(scroll.offset).toBe(0);
|
|
clock.advance(80);
|
|
expect(scroll.offset).toBeGreaterThan(0);
|
|
expect(scroll.offset).toBeLessThan(100);
|
|
clock.advance(200);
|
|
expect(scroll.offset).toBe(100);
|
|
expect(tween.moving).toBe(false);
|
|
});
|
|
|
|
it('adds up quick steps and stops at the end of the list', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, wheel } = list(clock);
|
|
wheel(100);
|
|
clock.advance(50);
|
|
wheel(100);
|
|
clock.advance(400);
|
|
expect(scroll.offset).toBe(200);
|
|
wheel(500);
|
|
clock.advance(400);
|
|
expect(scroll.offset).toBe(scroll.maxScroll);
|
|
});
|
|
|
|
it('lets go when the bar or a jump to a row moves the list', () => {
|
|
const clock = fakeClock();
|
|
const { scroll, tween, wheel } = list(clock);
|
|
wheel(100);
|
|
clock.advance(50);
|
|
scroll.scrollTo(250);
|
|
clock.advance(10);
|
|
expect(tween.moving).toBe(false);
|
|
expect(scroll.offset).toBe(250);
|
|
});
|
|
|
|
it('jumps at once with the transition switched off', () => {
|
|
const { scroll } = list(fakeClock());
|
|
scrollTween(scroll, () => 0, fakeClock());
|
|
scroll.wheelStep(() => scroll.scrollTo(60));
|
|
expect(scroll.offset).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe('the general settings', () => {
|
|
function memoryStore() {
|
|
const items = new Map<string, string>();
|
|
return {
|
|
items,
|
|
getItem: (key: string) => items.get(key) ?? null,
|
|
setItem: (key: string, value: string) => void items.set(key, value),
|
|
};
|
|
}
|
|
beforeEach(() => resetGeneralSettings());
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
resetGeneralSettings();
|
|
});
|
|
|
|
it('starts at 200 ms where nothing was kept', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
expect(generalSettings().zoomTransitionMs).toBe(200);
|
|
});
|
|
|
|
it('keeps the transition in the browser and reads it back on a new page', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
keepGeneralSettings({ zoomTransitionMs: 350 });
|
|
resetGeneralSettings();
|
|
expect(generalSettings().zoomTransitionMs).toBe(350);
|
|
expect(JSON.parse(store.items.get('feuerwerk.general-settings') ?? '')).toEqual({
|
|
zoomTransitionMs: 350,
|
|
showEmptyAssetGroups: false,
|
|
customCursor: true,
|
|
});
|
|
});
|
|
|
|
it('hides empty asset groups until switched on, keeps the switch and tells listeners (#361)', () => {
|
|
vi.stubGlobal('window', { localStorage: memoryStore() });
|
|
expect(generalSettings().showEmptyAssetGroups).toBe(false);
|
|
let told = 0;
|
|
const stop = onGeneralSettings(() => (told += 1));
|
|
keepGeneralSettings({ showEmptyAssetGroups: true });
|
|
resetGeneralSettings();
|
|
expect(generalSettings()).toEqual({
|
|
zoomTransitionMs: 200,
|
|
showEmptyAssetGroups: true,
|
|
customCursor: true,
|
|
});
|
|
stop();
|
|
keepGeneralSettings({ showEmptyAssetGroups: false });
|
|
expect(told).toBe(1);
|
|
});
|
|
|
|
it('shows the custom mouse pointer until switched off, and keeps the switch', () => {
|
|
const store = memoryStore();
|
|
vi.stubGlobal('window', { localStorage: store });
|
|
expect(generalSettings().customCursor).toBe(true);
|
|
keepGeneralSettings({ customCursor: false });
|
|
resetGeneralSettings();
|
|
expect(generalSettings().customCursor).toBe(false);
|
|
// Settings kept before the switch existed show it.
|
|
store.setItem('feuerwerk.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
|
|
resetGeneralSettings();
|
|
expect(generalSettings().customCursor).toBe(true);
|
|
});
|
|
|
|
it('holds the transition to whole ms from 0 to 1000', () => {
|
|
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
|
|
expect(zoomTransitionMs('fast')).toBe(200);
|
|
});
|
|
});
|