Preview inspector edits once per frame, not per input (#274)

Scrubbing a number in the inspector and dragging a colour stop of a
gradient told the editor about every input. Pixi repeats each pointer
move shortly after, so both gestures paid for two previews per move.
The issue asked to measure first and to build only from 10 % per
move. The measurement showed about 50 %.

- previewingHandlers still writes the model on every input but
  requests the preview through a FramePreview of its own. The commit
  cancels a waiting preview first.
- The preview handler of the GradientBar in shapeSection.ts does the
  same.
- Wider than the issue describes: the row handlers also serve typing,
  the arrow keys and the colour picker. All of them preview once per
  frame now.

Measured on stein2, fixture project with 120 extra objects, 120 moves
8 ms apart, three runs each:
- Scrub: 236 project events before, 51 to 61 after. 45 to 50 ms per
  move before, 22 to 27 after.
- Colour stop: 238 project events before, 50 to 51 after. 44 to 49 ms
  per move before, 22 after.
- One preview still costs 13 to 17 ms. That price per frame is what
  remains.

Finding, not fixed here: in the pixel art tab the palette colour and
the opacity of a layer still preview on every input
(PixelArtSide.ts), and so do the head fields of the curve tab.
Neither was measured.

Tests:
- Gate: 198 files, 2907 tests (2903 + 4), exit 0
- e2e, full run: 1616 checks passed (1615 + 1), exit 0
- Smokes: 24 / 19 / 21, exit 0
- Four new unit tests in tests/editor/frame-preview.test.ts, two for
  a row and two for a colour stop
- One new e2e check in module 02: a held scrub gave 10 project events
  in 12 frames, and the value is there before the release
- Four unit counter-probes fail, 1 to 2 tests each. They ran on
  stein2, not in the container.
- The browser counter-probe fails the new check with 50 events in 16
  frames

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 11:25:37 +02:00
1 parent c1cc80bb45
commit c4a9280140
5 files changed
+163 -9

No files matched your search

+1 -1
View File
@@ -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
+5 -1
View File
@@ -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<Target, Model = PropertyValue> {
/**
* 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<Target, Model = PropertyValue>(
host: { state: EditorState },
@@ -92,6 +94,7 @@ export function previewingHandlers<Target, Model = PropertyValue>(
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<Target, Model = PropertyValue>(
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) {
+4 -1
View File
@@ -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));
+124 -4
View File
@@ -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<typeof target, number>(
{ 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);
});
});
+29 -2
View File
@@ -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