The List tab could show one list at a time and was the only place to see its items. A list now shows its items right where it lives, and a new Variables tab shows all variables of an asset or placed object. - InlineList draws the items of a list under its row in the inspector, in struct fields and in the Variables tab. A list inside a list or a struct field is drawn the same way, one step indented. - The index column is the indent. Items sit under their list without an own margin, and the index column is 16 px wide instead of 28 px. - Above 20 items (INLINE_LIMIT) the rest collapses into "... N more - open in tab". During a run the rows show the values from the game. - ListHead puts "N items" and "+ Item" in one row. "N items" opens the list in a tab. A separate Open button squeezed the counter to zero width in the narrow inspector column. - VariablesEditor shows every variable of a behavior asset, a variables asset or a placed behavior. On an asset it edits the declarations. On a placed object an edit only overrides that object, with the usual reset and push buttons. - Opening a list inside a tab adds a new tab and keeps the open one. valuesKey gives each list its own document, so a list that is already open is shown instead of opened twice. - Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab closes when its asset or object is deleted. - Lists on node inputs stay inline only, a node owns no variables. - The List tab, ListEditor and the __gm.list hook are gone. Tests: - ./check.sh: 181 files, 2645 tests, CHECK EXIT 0 - GM_STEPS=1,21,22,32: ALL PASSED, 123 checks - e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand. - Counter-probes fail as expected: sub-lists not told apart (1 unit test), sub-list replacing the open tab, all items drawn inline (60 rows instead of 20), no live values (0 rows at 24 items in a run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
8.0 KiB
TypeScript
175 lines
8.0 KiB
TypeScript
import type { PropertyBag } from '../../core/model/Properties';
|
||
import type { FederatedPointerEvent } from 'pixi.js';
|
||
import { Label, Panel, pressCount, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
|
||
import { activateAsset } from '../actions';
|
||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
|
||
import type { InspectorHost } from './host';
|
||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||
import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection';
|
||
import {
|
||
buildObjectSection,
|
||
buildObjectsSection,
|
||
objectSignature,
|
||
refreshObjectSection,
|
||
} from './objectSection';
|
||
import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection';
|
||
import { INLINE_LIMIT } from '../variables/target';
|
||
import { inlineListsIn } from '../variables/VariablesEditor';
|
||
import type { InlineList } from '../list/InlineList';
|
||
|
||
/**
|
||
* Shows the properties of the current selection. The rows are generated from the PropertySchema
|
||
* by one section per selection kind (asset, scene object, graph node, texture frame); the panel
|
||
* only decides when a section has to be rebuilt and when its values merely refresh.
|
||
*/
|
||
export class Inspector extends Panel {
|
||
private readonly scroll = new ScrollContainer();
|
||
private host: InspectorHost;
|
||
private builtFor: Selection = null;
|
||
/** Describes the structure the rows were built for; a change means a rebuild. */
|
||
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;
|
||
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
|
||
liveVariables: (assetId: string) => PropertyBag | null = () => null;
|
||
private readonly releaseFocusHandler: () => void;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||
this.host = this.emptyHost();
|
||
this.scroll.setContent(this.host.body);
|
||
this.content.add(this.scroll, 1);
|
||
state.on('selection', () => this.rebuild());
|
||
state.on('load', () => this.rebuild());
|
||
state.on('project', () => this.refreshValues());
|
||
state.on('runtime', () => this.refreshLive());
|
||
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
|
||
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
|
||
});
|
||
this.rebuild();
|
||
}
|
||
|
||
/**
|
||
* A link in a reference row (#221): a click pings the asset in the asset panel, a double-click
|
||
* opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both
|
||
* presses land on the same link.
|
||
*/
|
||
private followReference(assetId: string, e: FederatedPointerEvent): void {
|
||
if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId);
|
||
else this.state.pingAsset(assetId);
|
||
}
|
||
|
||
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
|
||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||
this.releaseFocusHandler();
|
||
super.destroy(options);
|
||
}
|
||
|
||
/** 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 };
|
||
}
|
||
|
||
/** For the check scripts: every list drawn inline in here, at any depth. */
|
||
lists(): InlineList[] {
|
||
return inlineListsIn(this.host.body);
|
||
}
|
||
|
||
private emptyHost(): InspectorHost {
|
||
return {
|
||
state: this.state,
|
||
// Read through the state on every call: load and play mode replace the project object.
|
||
project: () => this.state.project,
|
||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||
followReference: (assetId, e) => this.followReference(assetId, e),
|
||
listLimit: INLINE_LIMIT,
|
||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Play mode: show what the behaviors are doing to the selected object, ~10× a second.
|
||
* Skipped while a field in here is being edited so the typed text is not overwritten.
|
||
*/
|
||
private refreshLive(): void {
|
||
const now = performance.now();
|
||
if (now - this.lastLiveRefresh < 100) 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();
|
||
this.signature = this.currentSignature();
|
||
|
||
const s = this.state.selection;
|
||
if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
|
||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
|
||
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
|
||
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
|
||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
|
||
else buildObjectSection(this.host, s.holderId, s.objectId);
|
||
this.scroll.setContent(this.host.body);
|
||
}
|
||
|
||
private currentSignature(): string {
|
||
const s = this.state.selection;
|
||
if (!s) return '';
|
||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
|
||
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
|
||
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
|
||
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
|
||
}
|
||
|
||
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
|
||
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;
|
||
}
|
||
if (!s) return;
|
||
const ok =
|
||
s.kind === 'asset'
|
||
? refreshAssetSection(this.host, s.assetId)
|
||
: s.kind === 'node'
|
||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||
: s.kind === 'frame'
|
||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||
: s.kind === 'shape'
|
||
? refreshShapeSection(this.host, s.assetId, s.shapeId)
|
||
: s.kind === 'annotation'
|
||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||
: s.kind === 'track'
|
||
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
|
||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||
if (!ok) this.rebuild();
|
||
}
|
||
}
|