/** 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, easeOut, 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: most of the way early, nothing past the end', () => { expect(easeOut(0)).toBe(0); expect(easeOut(1)).toBe(1); expect(easeOut(2)).toBe(1); expect(easeOut(0.5)).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, 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) { 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) { 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(); 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('game-maker.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('game-maker.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); }); });