Show lists in the inspector and variables in their own tab (#252)
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>
This commit is contained in:
1 parent
92fad6b9e3
commit
4857bb75e6
26 files changed
+1203
-427
No files matched your search
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior,
|
||||
* with the very rows the inspector draws for them – or one list, as deep inside them as it sits.
|
||||
* One editor for every such tab, pointed at the target of the one showing, like the asset editors.
|
||||
*/
|
||||
import type { Container } from 'pixi.js';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
|
||||
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
|
||||
import type { InspectorHost } from '../inspector/host';
|
||||
import { viewOf } from '../inspector/objectQueries';
|
||||
import { buildOwnRows, objectSignature, refreshObjectSection } from '../inspector/objectSection';
|
||||
import { InlineList, ListHead } from '../list/InlineList';
|
||||
import { type LiveVariables, resolveList } from '../list/slot';
|
||||
import type { EditorState, ValuesTarget } from '../state';
|
||||
|
||||
/** The asset or placed object whose variables `target` shows, when it still has any to show. */
|
||||
function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'variables' }>) {
|
||||
const { owner } = target;
|
||||
if (owner.kind === 'asset') {
|
||||
const asset = findAsset(state.project, owner.assetId);
|
||||
return asset?.type === 'behavior' || asset?.type === 'variables' ? { asset, name: asset.name } : null;
|
||||
}
|
||||
const view = viewOf(state.project, owner.holderId);
|
||||
const object = view && findObject(view, owner.objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
return object && asset?.type === 'behavior' ? { asset, name: object.name || asset.name } : null;
|
||||
}
|
||||
|
||||
/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */
|
||||
export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null {
|
||||
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
|
||||
const owner = ownerOf(state, target);
|
||||
return owner ? `${owner.name} · Variables` : null;
|
||||
}
|
||||
|
||||
export class VariablesEditor extends VStack {
|
||||
private readonly title = new Label('', { style: 'bodyBold' });
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private host: InspectorHost;
|
||||
private target: ValuesTarget | null = null;
|
||||
private signature = '';
|
||||
private lastLiveRefresh = 0;
|
||||
private rebuildWhenLeft = false;
|
||||
private readonly releaseFocusHandler: () => void;
|
||||
/** The list a list target draws, and its head. */
|
||||
private list: { head: ListHead; items: InlineList } | null = null;
|
||||
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
|
||||
liveVariables: LiveVariables = () => null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ gap: 0 });
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + 2 * Theme.padding;
|
||||
header.add(this.title, 1);
|
||||
this.add(header);
|
||||
this.host = this.emptyHost();
|
||||
this.scroll.setContent(this.host.body);
|
||||
this.add(this.scroll, 1);
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('runtime', () => this.refreshLive());
|
||||
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
|
||||
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
|
||||
});
|
||||
}
|
||||
|
||||
/** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */
|
||||
override destroy(options?: Parameters<VStack['destroy']>[0]): void {
|
||||
this.releaseFocusHandler();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
/** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */
|
||||
show(target: ValuesTarget): void {
|
||||
this.target = target;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/** For the check scripts: what the tab shows, and the lists drawn in it. */
|
||||
debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } {
|
||||
return {
|
||||
title: this.title.text,
|
||||
target: this.target,
|
||||
rows: [...this.host.fields.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
/** Every list drawn in the tab, at any depth. */
|
||||
lists(): InlineList[] {
|
||||
return inlineListsIn(this.host.body);
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
return {
|
||||
state: this.state,
|
||||
project: () => this.state.project,
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
};
|
||||
}
|
||||
|
||||
private currentSignature(): string {
|
||||
const target = this.target;
|
||||
if (!target) return '';
|
||||
if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing';
|
||||
const { owner } = target;
|
||||
if (!ownerOf(this.state, target)) return 'missing';
|
||||
return owner.kind === 'asset'
|
||||
? assetSignature(this.state.project, owner.assetId)
|
||||
: objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId);
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.rebuildWhenLeft = false;
|
||||
this.host.body.destroy({ children: true });
|
||||
this.host = this.emptyHost();
|
||||
this.list = null;
|
||||
this.signature = this.currentSignature();
|
||||
const target = this.target;
|
||||
this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? '');
|
||||
if (target?.kind === 'list') this.buildList(target.slot);
|
||||
else if (target) {
|
||||
const owner = ownerOf(this.state, target);
|
||||
if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true);
|
||||
else if (target.owner.kind === 'object')
|
||||
buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true);
|
||||
}
|
||||
this.scroll.setContent(this.host.body);
|
||||
}
|
||||
|
||||
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
|
||||
const access = () => resolveList(this.state, slot, this.liveVariables);
|
||||
const items = new InlineList(this.host, access);
|
||||
const head = new ListHead(access, (kind) => items.addItem(kind), false);
|
||||
items.head = head;
|
||||
this.host.body.add(head);
|
||||
this.host.body.add(items);
|
||||
this.list = { head, items };
|
||||
}
|
||||
|
||||
/** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */
|
||||
private refreshLive(): void {
|
||||
if (!this.visible) return;
|
||||
const now = performance.now();
|
||||
if (now - this.lastLiveRefresh < 100) return;
|
||||
if (ui().focus.isWithin(this)) return;
|
||||
this.lastLiveRefresh = now;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const target = this.target;
|
||||
if (!target) return;
|
||||
this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? '');
|
||||
if (this.currentSignature() !== this.signature) {
|
||||
if (ui().focus.isWithin(this)) {
|
||||
this.rebuildWhenLeft = true;
|
||||
return;
|
||||
}
|
||||
this.rebuild();
|
||||
return;
|
||||
}
|
||||
if (target.kind === 'list') {
|
||||
this.list?.head.setCount(this.list.items.access()?.value.length ?? 0);
|
||||
this.list?.items.refresh();
|
||||
return;
|
||||
}
|
||||
const { owner } = target;
|
||||
const ok =
|
||||
owner.kind === 'asset'
|
||||
? refreshAssetSection(this.host, owner.assetId)
|
||||
: refreshObjectSection(this.host, owner.holderId, owner.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
/** Every inline list below `root`, outermost first – what the check scripts pick one from. */
|
||||
export function inlineListsIn(root: Container): InlineList[] {
|
||||
const found: InlineList[] = [];
|
||||
const walk = (node: Container): void => {
|
||||
if (node instanceof InlineList) found.push(node);
|
||||
for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
Reference in new issue
Block a user