Show the list a running game fills in the list tab (#251)
game-maker / gate (push) Successful in 3m23s
game-maker / suite (push) Successful in 10m16s

While playing, the inspector said "2 items" for a list a graph had
filled, but clicking it opened an empty list tab. The inspector reads a
Variables asset from the running game, the list tab only read the list
stored in the project. It did not follow the run's ticks either.

- resolveList takes the live values. For a Variables asset it uses the
  running game's value while one runs, the project's otherwise. This is
  the inspector's rule.
- ListEditor refreshes on runtime ticks about 10 times a second, and
  not while a field in the tab is being typed in, like the inspector.
- Editor hands the list editor the inspector's liveVariables.
- __gm.list.info() also returns rows, the rows the last build drew.
- Editing in the tab while playing goes into the run's copy of the
  project, as in the inspector. A very long, growing list rebuilds all
  rows up to 10 times a second.

Tests:
- ./check.sh: 180 files, 2634 tests, CHECK EXIT 0
- e2e GM_STEPS=1,32: ALL PASSED, 30 checks
- e2e 32b-ii, 2 checks: a graph adds an item every frame, the test
  pauses and clicks "N items" with a real mouse. The tab draws 26 rows
  for 26 items, grows to 50 after resuming and is empty after Stop.
- Counter-checks: without live values the tab draws 0 rows for 23
  items, and without the refresh it stays at 23
- README: 1481 editor checks, counted by hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 21:07:14 +02:00
1 parent b90b62886b
commit 92fad6b9e3
5 files changed
+126 -9

No files matched your search

+1
View File
@@ -320,6 +320,7 @@ export class Editor implements PlayHost {
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`).
this.inspector.liveVariables = (assetId) =>
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
this.listEditor.liveVariables = this.inspector.liveVariables;
this.usersPane =
account.role === 'admin'
+21 -4
View File
@@ -15,6 +15,7 @@ import {
Spacer,
TextField,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
@@ -50,7 +51,7 @@ import {
} from '../inspector/fieldFactory';
import { kindMenu, markPicker } from '../inspector/variableRows';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
import { type ListAccess, type LiveVariables, resolveList } from './slot';
const INDEX_WIDTH = 40;
@@ -89,6 +90,10 @@ export class ListEditor extends VStack {
private signature = '';
private setters: ((item: PropertyValue) => void)[] = [];
private rowWidgets: RowWidgets[] = [];
/** Last live refresh (play mode), in ms – the same pace as the inspector's. */
private lastLiveRefresh = 0;
/** 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 });
@@ -117,9 +122,19 @@ export class ListEditor extends VStack {
state.on('list', () => this.rebuild());
state.on('load', () => this.rebuild());
state.on('project', () => this.refresh());
state.on('runtime', () => this.refreshLive());
this.rebuild();
}
/** Play mode: follow a list the behaviors fill, ~10× a second, but never under a field being typed in. */
private refreshLive(): void {
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refresh();
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number, field = ''): { x: number; y: number } | null {
const row = this.rowWidgets[index];
@@ -143,10 +158,12 @@ export class ListEditor extends VStack {
}
/** For the check scripts: what the tab shows and how its rows read right now. */
debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[] } {
debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[]; rows: number } {
const access = this.access();
if (!access) return { title: '', items: [], kinds: [], offers: [] };
if (!access) return { title: '', items: [], kinds: [], offers: [], rows: this.rowWidgets.length };
return {
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
rows: this.rowWidgets.length,
title: access.title,
items: access.value.map((item) => textOf(item)),
kinds: access.value.map(itemKindOf),
@@ -156,7 +173,7 @@ export class ListEditor extends VStack {
private access(): ListAccess | null {
const slot = this.state.listSlot;
return slot ? resolveList(this.state, slot) : null;
return slot ? resolveList(this.state, slot, this.liveVariables) : null;
}
/** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */
+22 -4
View File
@@ -3,12 +3,13 @@
* edit gets the same command, coercion and undo step. A slot with a `path` still writes the whole value.
*/
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { withoutObjects } from '../../core/graph/liveObjects';
import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
import { itemLike } from '../../core/properties/listItems';
@@ -59,9 +60,16 @@ interface Owner<Target> {
spec: Omit<PreviewedValue<Target>, 'toModel'>;
}
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
/** A Variables asset's values in the running game, or null while none runs it. */
export type LiveVariables = (assetId: string) => PropertyBag | null;
export function resolveList(
state: EditorState,
slot: ListSlot,
live: LiveVariables = () => null,
): ListAccess | null {
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot);
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot, live));
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
return inSlot(nodeOwner(state, slot));
}
@@ -149,7 +157,15 @@ function access<Target>(
};
}
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
/**
* While a run is on, a Variables asset shows what the run holds – the same rule as the inspector's
* `shownValues`, so a list a graph fills is not "2 items" in the row and empty in the tab.
*/
function assetOwner(
state: EditorState,
slot: Extract<ListSlot, { kind: 'asset' }>,
live: LiveVariables,
): Owner<Asset> | null {
const asset = findAsset(state.project, slot.assetId);
const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key);
if (!asset || !def) return null;
@@ -158,6 +174,8 @@ function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset'
title: `${asset.name} · ${def.label}`,
current: () => {
const now = findAsset(state.project, slot.assetId);
const bag = now?.type === 'variables' ? live(now.id) : null;
if (bag && slot.key in bag) return withoutObjects(bag[slot.key]);
return now && resolveAssetProps(state.project, now)[slot.key];
},
spec: assetValue(state, slot.assetId, slot.key),