diff --git a/README.md b/README.md index ad809497..0db0f674 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 1db1d939..c45b5d8f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 35cf6c26..d465728e 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 042888f4..069cc85d 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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; } diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index 57f571c6..6a1f3b21 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -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 { diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index de8b3d34..274afa61 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -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 = ''"); }