diff --git a/README.md b/README.md index a27c3785..48161ccd 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1615 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1616 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index c65941c6..3fcbf137 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -8,6 +8,7 @@ import { import type { Command } from '../../core/commands/Command'; import type { PropertyValue } from '../../core/model/Properties'; import { type ValuePath, withValueAt } from '../../core/properties/valuePath'; +import { FramePreview } from '../framePreview'; import type { EditorState, ListSlot } from '../state'; import { type LiveVariables, resolveList } from '../list/slot'; import type { FieldHandlers } from './fieldFactory'; @@ -84,6 +85,7 @@ export interface PreviewedValue { /** * Row handlers that preview into the model and restore the pre-preview value (or its absence, via * `remove`) before committing, since a command records whatever it finds at execute time as its undo target. + * The model is written on every input, the preview event waits for the frame (#274). */ export function previewingHandlers( host: { state: EditorState }, @@ -92,6 +94,7 @@ export function previewingHandlers( const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model); let previewed = false; let before: Model | undefined; + const perFrame = new FramePreview(host.state, 'inspector-input'); return { onInput: (input) => { const target = spec.target(); @@ -101,9 +104,10 @@ export function previewingHandlers( before = spec.read(target); } spec.write(target, toModel(input, target)); - host.state.preview(); + perFrame.request(); }, onChange: (input) => { + perFrame.cancel(); const target = spec.target(); if (!target) return; if (previewed) { diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index e8007b35..2468089a 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -52,6 +52,7 @@ import { import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, HStack, Theme } from '../../ui'; import { removeShapes } from '../actions'; +import { FramePreview } from '../framePreview'; import { type EditorState, shapeSelection } from '../state'; import { GradientBar } from '../vectorart/GradientBar'; import { ShapeList } from '../vectorart/ShapeList'; @@ -627,6 +628,7 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par const shape = currentShape(host, assetId, shapeId); return shape ? gradientIn(shape, part) : undefined; }; + const perFrame = new FramePreview(host.state, 'gradient-stop'); const bar = new GradientBar(host.state, { gradient: gradientOf, selected: () => pickedStop(assetId, shapeId, part, gradientOf()), @@ -640,9 +642,10 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par const shape = currentShape(host, assetId, shapeId); if (!shape) return; applyPreview(shape, withGradient(shape, part, gradient)); - host.state.preview(); + perFrame.request(); }, commit: (before, after, label) => { + perFrame.cancel(); const shape = currentShape(host, assetId, shapeId); if (!shape) return; applyPreview(shape, withGradient(shape, part, before)); diff --git a/tests/editor/frame-preview.test.ts b/tests/editor/frame-preview.test.ts index 20dfcb5e..794976df 100644 --- a/tests/editor/frame-preview.test.ts +++ b/tests/editor/frame-preview.test.ts @@ -2,14 +2,22 @@ * A drag writes the model on every pointer move but tells the editor once per frame (#213): the * model event rebuilds panels and pictures, and a mouse reports faster than the screen draws. */ -import type { Application } from 'pixi.js'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { GraphAsset } from '../../src/core/model/Asset'; +import { type Application, CanvasTextMetrics } from 'pixi.js'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildAsset } from '../../src/core/commands/assetCommands'; +import type { Command } from '../../src/core/commands/Command'; +import type { GraphAsset, VectorArtAsset, VectorGradient } from '../../src/core/model/Asset'; +import { createEmptyProject } from '../../src/core/model/Project'; import { FramePreview } from '../../src/editor/framePreview'; import { GraphDrags } from '../../src/editor/graph/graphDrags'; import type { GraphCanvasHost } from '../../src/editor/graph/graphHost'; -import type { EditorState } from '../../src/editor/state'; +import type { InspectorHost } from '../../src/editor/inspector/host'; +import { previewingHandlers } from '../../src/editor/inspector/previewHandlers'; +import { buildShapeSection, currentGradientBar } from '../../src/editor/inspector/shapeSection'; +import { EditorState } from '../../src/editor/state'; +import { VStack } from '../../src/ui'; import { initRenderLoop, resetRenderLoop } from '../../src/ui/render'; +import { PaintCache } from '../../src/viewport/vectorArtPicture'; /** One frame of the render loop, the way the ticker runs it. */ function frames(): () => void { @@ -117,3 +125,115 @@ describe('dragging a node in the graph', () => { expect(state.preview).not.toHaveBeenCalled(); }); }); + +describe('editing an inspector row (#274)', () => { + beforeEach(() => resetRenderLoop()); + + function rig() { + const state = fakeState(); + const target = { x: 1 }; + const commit = vi.fn((value: number) => ({ value }) as unknown as Command); + const handlers = previewingHandlers( + { state: state as unknown as EditorState }, + { target: () => target, read: (t) => t.x, write: (t, value) => (t.x = value), commit }, + ); + return { state, target, commit, handlers }; + } + + it('writes every input into the model and previews once a frame', () => { + const tick = frames(); + const { state, target, handlers } = rig(); + for (let i = 2; i <= 6; i++) { + handlers.onInput(i); + expect(target.x).toBe(i); + } + expect(state.preview).not.toHaveBeenCalled(); + tick(); + expect(state.preview).toHaveBeenCalledTimes(1); + }); + + it('commits from the value before the scrub and leaves nothing for the next frame', () => { + const tick = frames(); + const { state, target, commit, handlers } = rig(); + handlers.onInput(5); + handlers.onChange(7); + expect(target.x).toBe(1); + expect(commit).toHaveBeenCalledWith(7); + expect(state.execute).toHaveBeenCalledTimes(1); + tick(); + expect(state.preview).not.toHaveBeenCalled(); + }); +}); + +describe('dragging a colour stop of a gradient (#274)', () => { + beforeAll(() => { + CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; + PaintCache.prototype.gradient = (() => null) as never; + }); + beforeEach(() => resetRenderLoop()); + + function rig() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; + asset.shapes.push({ + id: 's', + name: 'Sky', + fill: 0x0000ff, + opacity: 1, + rings: [], + paint: { + type: 'linear', + from: { x: 0, y: 0 }, + to: { x: 100, y: 0 }, + stops: [ + { offset: 0, color: 0x0000ff, alpha: 1 }, + { offset: 1, color: 0xffffff, alpha: 1 }, + ], + }, + }); + state.project.assets.push(asset); + const host: InspectorHost = { + state, + body: new VStack(), + fields: new Map(), + nameField: null, + project: () => state.project, + liveVariables: () => null, + }; + buildShapeSection(host, asset.id, 's', ['s']); + const bar = currentGradientBar('fill')!; + // 100 px between the outermost markers, which stand 6 px in from either end. + bar.resize(112, 32); + const pointer = (type: string, x: number) => + bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined }); + const offsets = () => (asset.shapes[0]!.paint as VectorGradient).stops.map((stop) => stop.offset); + return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets }; + } + + it('moves the stop with every pointer move and previews once a frame', () => { + const tick = frames(); + const { preview, pointer, offsets } = rig(); + pointer('pointerdown', 6); + for (let i = 1; i <= 5; i++) { + pointer('globalpointermove', 6 + i * 10); + expect(offsets()[0]).toBeCloseTo(i / 10); + } + expect(preview).not.toHaveBeenCalled(); + tick(); + expect(preview).toHaveBeenCalledTimes(1); + }); + + it('leaves nothing for the frame after the release, and the drag is one undo step', () => { + const tick = frames(); + const { state, preview, pointer, offsets } = rig(); + pointer('pointerdown', 6); + pointer('globalpointermove', 56); + pointer('pointerup', 56); + expect(offsets()[0]).toBeCloseTo(0.5); + tick(); + expect(preview).not.toHaveBeenCalled(); + state.undo(); + expect(offsets()[0]).toBe(0); + }); +}); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index b9a4bf14..9fb70282 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -10,6 +10,9 @@ export async function inspector(t) { locate, shot, click, + mouse, + evaluate, + settle, doubleClick, clickText, drag, @@ -123,11 +126,35 @@ export async function inspector(t) { JSON.stringify(backward), ); - // 2e. Scrub the X field (right of the "X" label) by 100 px. + // 2e. Scrub the X field (right of the "X" label) by 100 px. The model follows every move, the + // editor hears of it once per frame at most (#274): counted while the button is still held. const xLabel = await locate('X'); - await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y); + await evaluate(`(() => { + const seen = (window.__scrub02 = { events: 0, frames: 0, done: false }); + seen.stop = __gm.state.on('project', () => (seen.events += 1)); + const frame = () => { if (seen.done) return; seen.frames += 1; requestAnimationFrame(frame); }; + requestAnimationFrame(frame); + })()`); + await mouse('mouseMoved', xLabel.x + 160, xLabel.y); + await mouse('mousePressed', xLabel.x + 160, xLabel.y); + for (let i = 1; i <= 25; i++) { + await mouse('mouseMoved', xLabel.x + 160 + i * 4, xLabel.y, 'left', { buttons: 1 }); + await sleep(8); + } + await settle(); + const held = await json( + '(() => { const s = window.__scrub02; s.done = true; s.stop(); return { events: s.events, frames: s.frames }; })()', + ); + const xHeld = (await object('o2')).position.x; + await mouse('mouseReleased', xLabel.x + 260, xLabel.y); + await settle(); const xNow = (await object('o2')).position.x; check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); + check( + 'a held scrub writes every move and previews once per frame at most', + xHeld === 220 && xNow === 220 && held.events >= 1 && held.events <= held.frames, + JSON.stringify({ xHeld, xNow, ...held }), + ); await shot('05-scrubbed'); // 2f. Type into X, then click straight into the empty viewport: the typed value must be