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

+55 -33
View File
@@ -44,8 +44,8 @@ import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { resolveList } from './list/slot';
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
import { valuesKey } from './variables/target';
import { AnimationEditor } from './animation/AnimationEditor';
import { TileEditor } from './tile/TileEditor';
import { AssetPanel } from './panels/AssetPanel';
@@ -66,7 +66,7 @@ import { ShaderEditor } from './shader/ShaderEditor';
import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts';
import type { PlayFrom } from '../core/model/playSettings';
import type { EditorState } from './state';
import type { EditorState, ValuesTarget } from './state';
import { copySelection } from './clipboard';
import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor';
@@ -92,7 +92,7 @@ export type CentreTab =
| 'curve'
| 'tile'
| 'container'
| 'list'
| 'variables'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -100,10 +100,11 @@ export type PluginTabId = `plugin:${string}`;
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or the list the List tab edits – there is one. The
* user management (#179) is no document, but it opens and closes like one.
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it
* opens and closes like one.
*/
export type DocumentId = `asset:${string}` | 'list' | 'management';
export type DocumentId = `asset:${string}` | `values:${string}` | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -121,7 +122,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'list' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -139,7 +140,7 @@ const PANE_ORDER = [
'curve',
'tile',
'container',
'list',
'variables',
'management',
] as const;
@@ -158,7 +159,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
curve: 'Curve',
tile: 'Tile',
container: 'Container',
list: 'List',
variables: 'Variables',
management: 'User management',
};
@@ -169,7 +170,7 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'list' || tab === 'management' || tab.startsWith('asset:');
return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:');
}
const MIN_LEFT_WIDTH = 160;
@@ -204,7 +205,9 @@ export class Editor implements PlayHost {
readonly curveEditor: CurveEditor;
readonly tileEditor: TileEditor;
readonly containerEditor: ContainerEditor;
readonly listEditor: ListEditor;
readonly variablesEditor: VariablesEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
@@ -284,7 +287,7 @@ export class Editor implements PlayHost {
this.curveEditor = new CurveEditor(state);
this.tileEditor = new TileEditor(state);
this.containerEditor = new ContainerEditor(state);
this.listEditor = new ListEditor(state);
this.variablesEditor = new VariablesEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -320,7 +323,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.variablesEditor.liveVariables = this.inspector.liveVariables;
this.usersPane =
account.role === 'admin'
@@ -395,7 +398,7 @@ export class Editor implements PlayHost {
scene: this.scenePane,
game: this.gamePane,
...this.assetEditors,
list: this.listEditor,
variables: this.variablesEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -472,10 +475,13 @@ export class Editor implements PlayHost {
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
else this.openDocument(`asset:${s.assetId}`);
});
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
state.on('list', () => {
if (state.listSlot) this.openDocument('list');
else this.closeDocument('list');
// A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252).
state.on('values', () => {
const target = state.openedValues;
if (!target) return;
const doc: DocumentId = `values:${valuesKey(target)}`;
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
state.on('project', () => {
this.play.syncFromModel();
@@ -486,6 +492,7 @@ export class Editor implements PlayHost {
this.editorRun.invalidate();
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.invalidate());
@@ -614,7 +621,7 @@ export class Editor implements PlayHost {
openDocument(doc: DocumentId): void {
if (!this.kindOf(doc)) return;
if (this.documents.active === doc) {
// Still worth a look at the name: the List tab may have been pointed at another list.
// Still worth a look at the name: a Variables tab's owner may have been renamed.
this.syncStrip();
return;
}
@@ -632,8 +639,7 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
// The List tab edits the one slot there is; a closed tab lets go of it.
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
this.valueTargets.delete(doc);
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene');
@@ -645,8 +651,9 @@ export class Editor implements PlayHost {
* a document without knowing its tab.
*/
showTab(tab: TabId): void {
if (tab === 'list') {
if (this.state.listSlot) this.openDocument('list');
if (tab === 'variables') {
const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:'));
if (doc) this.openDocument(doc);
return;
}
if (tab === 'management') {
@@ -672,7 +679,10 @@ export class Editor implements PlayHost {
}
private kindOf(doc: DocumentId): CentreTab | null {
if (doc === 'list') return this.state.listSlot ? 'list' : null;
if (doc.startsWith('values:')) {
const target = this.valueTargets.get(doc);
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management') return 'management';
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
@@ -683,25 +693,27 @@ export class Editor implements PlayHost {
const kind = this.kindOf(doc);
if (!kind) return;
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
const target = this.valueTargets.get(doc);
if (kind === 'variables' && target) this.variablesEditor.show(target);
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'list') {
const slot = this.state.listSlot;
return (slot && resolveList(this.state, slot)?.title) ?? '';
}
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
}
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
}
@@ -764,8 +776,18 @@ export class Editor implements PlayHost {
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
tabRow(): {
shown: TabPlace<StripTab>[];
hidden: StripTab[];
order: StripTab[];
labels: Record<string, string>;
} {
return {
shown: this.tabs.places(),
hidden: this.tabs.hidden(),
order: [...this.tabs.tabs],
labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])),
};
}
boot(): Promise<void> {