Show lists in the inspector and variables in their own tab (#252)
game-maker / gate (push) Successful in 3m24s
game-maker / suite (push) Successful in 10m22s

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:
adminandClaude Opus 5.5 committed 2026-09-27 21:56:21 +02:00
1 parent 92fad6b9e3
commit 4857bb75e6
26 files changed
+1203 -427

No files matched your search

+189
View File
@@ -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;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* The rules of the Variables tab (#252) that need no widget: which document a target is, and how
* much of a list is drawn inline.
*/
import type { ListSlot, ValuesTarget, VariablesOwner } from '../state';
/**
* A key that is the same for the same target however it was built – the document id of its tab,
* so asking twice for one list brings up its tab instead of opening a second.
*/
export function valuesKey(target: ValuesTarget): string {
if (target.kind === 'variables') return `vars|${ownerKey(target.owner)}`;
return `list|${slotKey(target.slot)}`;
}
function ownerKey(owner: VariablesOwner): string {
return owner.kind === 'asset' ? `asset|${owner.assetId}` : `object|${owner.holderId}|${owner.objectId}`;
}
function slotKey(slot: ListSlot): string {
const where =
slot.kind === 'asset'
? `asset|${slot.assetId}`
: slot.kind === 'object'
? `object|${slot.holderId}|${slot.objectId}`
: `node|${slot.assetId}|${slot.nodeId}`;
return `${where}|${JSON.stringify([slot.key, ...(slot.path ?? [])])}`;
}
/** The list one step further down: `inner` leads from the slot's list to the one inside it. */
export function deeperSlot(slot: ListSlot, inner: ListSlot['path']): ListSlot {
return { ...slot, path: [...(slot.path ?? []), ...(inner ?? [])] };
}
/** How many items the inspector draws of a list before it says "… N more". */
export const INLINE_LIMIT = 20;
/** The items drawn and the ones left out: all of them without a limit. */
export function shownItems(count: number, limit = Infinity): { shown: number; more: number } {
const shown = Math.min(count, Math.max(0, limit));
return { shown, more: count - shown };
}