Hold back an inspector rebuild while a field in it has focus
A rebuild destroys the widget being typed in, and a typed value can change the very structure that decides whether a rebuild is needed -- a field that says how many ports a node has. That made such a field unusable: every keystroke previewed, the preview emitted 'project', refreshValues() saw the signature change and threw the row away. Dragging the value worked only because a drag keeps receiving globalpointermove on the destroyed widget. So refreshValues() now postpones a due rebuild while the focus sits inside the inspector, and runs it as soon as the field is left. Values are safe in the meantime: TextField.setValue ignores an edit in progress. Two handles for that in the toolkit. FocusManager.isWithin(node) also replaces the parent chain refreshLive() walked by hand, and changeHandlers lets a panel notice that the focus has gone -- there was no signal for that before. Nothing in the editor can trigger this today, since the Sequence setting that did is gone. It is the ground for the List tab, where a row that adds or deletes an item changes the structure while you type. The e2e check therefore builds the case out of real parts: type into a field, set the object's parent from outside -- the parent is part of the structure signature -- and confirm the input survives and the rebuild follows on Escape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c76106fd2
commit
5a440ba433
6 files changed
+73
-5
No files matched your search
@@ -563,7 +563,7 @@ npm run format # Prettier over src, tests and tools
|
||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 416 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 419 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
|
||||
|
||||
@@ -44,6 +44,7 @@ export class Editor implements PlayHost {
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
readonly assets: AssetPanel;
|
||||
readonly inspector: Inspector;
|
||||
tab: CentreTab = 'scene';
|
||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||
fps: number | null = null;
|
||||
@@ -51,7 +52,6 @@ export class Editor implements PlayHost {
|
||||
private readonly editorRun: EditorRun;
|
||||
private readonly projects: ProjectSession;
|
||||
private readonly leftColumn: VStack;
|
||||
private readonly inspector: Inspector;
|
||||
private readonly leftSplitter = new Splitter('x');
|
||||
private readonly rightSplitter = new Splitter('x');
|
||||
private readonly workspace: HStack;
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { EditorState } from './state';
|
||||
* visible text and to read editor state.
|
||||
*/
|
||||
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||||
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy, assets } = editor;
|
||||
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy, assets, inspector } =
|
||||
editor;
|
||||
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
@@ -159,6 +160,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
|
||||
locate,
|
||||
inspectorField: fieldOf,
|
||||
inspectorInfo: () => inspector.debugInfo(),
|
||||
textColor,
|
||||
overlayTexts,
|
||||
overlayRect,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
@@ -25,6 +24,9 @@ export class Inspector extends Panel {
|
||||
private signature = '';
|
||||
/** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */
|
||||
private lastLiveRefresh = 0;
|
||||
/** A rebuild that was due while a field in here had focus (see `refreshValues`). */
|
||||
private rebuildWhenLeft = false;
|
||||
private rebuilds = 0;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||||
@@ -35,9 +37,17 @@ export class Inspector extends Panel {
|
||||
state.on('load', () => this.rebuild());
|
||||
state.on('project', () => this.refreshValues());
|
||||
state.on('runtime', () => this.refreshLive());
|
||||
ui().focus.changeHandlers.push(() => {
|
||||
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
|
||||
});
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/** For the check scripts: whether a rebuild is being held back, and how many have run. */
|
||||
debugInfo(): { rebuilds: number; postponed: boolean } {
|
||||
return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft };
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
return {
|
||||
state: this.state,
|
||||
@@ -56,12 +66,14 @@ export class Inspector extends Panel {
|
||||
private refreshLive(): void {
|
||||
const now = performance.now();
|
||||
if (now - this.lastLiveRefresh < 100) return;
|
||||
for (let node: Container | null = ui().focus.focused; node; node = node.parent) if (node === this) return;
|
||||
if (ui().focus.isWithin(this)) return;
|
||||
this.lastLiveRefresh = now;
|
||||
this.refreshValues();
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.rebuildWhenLeft = false;
|
||||
this.rebuilds += 1;
|
||||
this.builtFor = this.state.selection;
|
||||
this.host.body.destroy({ children: true });
|
||||
this.host = this.emptyHost();
|
||||
@@ -90,6 +102,13 @@ export class Inspector extends Panel {
|
||||
private refreshValues(): void {
|
||||
const s = this.state.selection;
|
||||
if (!sameSelection(s, this.builtFor) || this.currentSignature() !== this.signature) {
|
||||
// A rebuild throws away the widget being typed in, and a typed value can change the
|
||||
// structure itself (a field that decides how many ports a node has). So it waits until
|
||||
// the field is left; values are safe meanwhile, `TextField.setValue` ignores an edit.
|
||||
if (ui().focus.isWithin(this)) {
|
||||
this.rebuildWhenLeft = true;
|
||||
return;
|
||||
}
|
||||
this.rebuild();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { collectFocusables, type Widget } from './Widget';
|
||||
export class FocusManager {
|
||||
/** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */
|
||||
readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = [];
|
||||
/** Called after focus moved, so a panel can catch up on work it postponed while being typed in. */
|
||||
readonly changeHandlers: (() => void)[] = [];
|
||||
private focusedWidget: Widget | null = null;
|
||||
private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event);
|
||||
|
||||
@@ -26,6 +28,15 @@ export class FocusManager {
|
||||
this.focusedWidget = widget;
|
||||
previous?.onBlur();
|
||||
widget?.onFocus();
|
||||
for (const handler of this.changeHandlers) handler();
|
||||
}
|
||||
|
||||
/** Whether the focused widget is `node` or sits inside it. */
|
||||
isWithin(node: Container): boolean {
|
||||
for (let current: Container | null = this.focusedWidget; current; current = current.parent) {
|
||||
if (current === node) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
|
||||
@@ -142,4 +142,40 @@ export async function inspector(t) {
|
||||
await redo();
|
||||
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
|
||||
await shot('07-undo-redo');
|
||||
|
||||
// 7. A change to the *structure* of what is shown normally rebuilds the inspector, which
|
||||
// destroys the widget being typed in. While a field in here has focus the rebuild has to
|
||||
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
|
||||
await clickText('Renamed crate');
|
||||
const xRow = await locate('X');
|
||||
await click(xRow.x + 160, xRow.y);
|
||||
// The value it already has: Escape cancels the widget but not the preview it wrote, so
|
||||
// typing anything else here would leave that value behind for the later steps.
|
||||
await typeText('120');
|
||||
const before = await json('__gm.inspectorInfo()');
|
||||
// The parent is part of the object's structure signature, so this asks for a rebuild.
|
||||
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = 'o1'");
|
||||
const during = await json('__gm.inspectorInfo()');
|
||||
const stillTyping = await json(
|
||||
'({ tag: document.activeElement?.tagName, value: document.activeElement?.value })',
|
||||
);
|
||||
check(
|
||||
'a structure change while typing does not rebuild the inspector',
|
||||
during.rebuilds === before.rebuilds && during.postponed,
|
||||
JSON.stringify(during),
|
||||
);
|
||||
check(
|
||||
'the field being typed in survives it',
|
||||
stillTyping.tag === 'INPUT' && stillTyping.value === '120',
|
||||
JSON.stringify(stillTyping),
|
||||
);
|
||||
await press('Escape');
|
||||
await sleep(200);
|
||||
const after = await json('__gm.inspectorInfo()');
|
||||
check(
|
||||
'leaving the field runs the postponed rebuild',
|
||||
after.rebuilds === before.rebuilds + 1 && !after.postponed,
|
||||
JSON.stringify(after),
|
||||
);
|
||||
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = ''");
|
||||
}
|
||||
Reference in new issue
Block a user