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

+1 -1
View File
@@ -1,5 +1,5 @@
/**
* The address the List tab edits through. What matters here is not that a list can be read, but
* The address an inline list edits through (#252). What matters here is not that a list can be read, but
* that writing one item goes through the same command (and the same undo) as the inspector row
* it replaces, and that a row keeps answering for the list as it is *now*.
*/
+17 -11
View File
@@ -1,5 +1,5 @@
/**
* Editing a value three levels deep (#239): the List tab reaches a nested list by a path of keys
* Editing a value three levels deep (#239): an inline list reaches a nested list by a path of keys
* and indices, an inspector row a nested struct field by its keys. Either way one edit is one
* command on the whole value, so undo takes back exactly that edit and the neighbours stay.
*/
@@ -77,15 +77,22 @@ describe('a list nested in the items of a list', () => {
expect(props(f).rows).toEqual(rows);
});
it('opens the tab one level further down', () => {
at([0, 'drops'])!.openList([1, 'x']);
expect(f.state.listSlot).toEqual({
kind: 'asset',
assetId: f.id,
key: 'rows',
path: [0, 'drops', 1, 'x'],
it('opens a tab one level further down', () => {
at([0, 'drops'])!.openList!([1, 'x']);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'rows', path: [0, 'drops', 1, 'x'] },
});
});
it('reaches a list inside it for drawing it inline, writing the whole value', () => {
const inner = at([0, 'drops'])!.sub([1, 'x'])!;
expect(inner.title).toBe(at([0, 'drops', 1, 'x'])!.title);
expect(inner.value).toEqual([2, 3]);
inner.replace([2, 3, 4]);
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 3, 4] }] }, 'plain']);
expect(at([0, 'drops'])!.sub([0])).toBe(null);
});
});
describe('a field of a struct declared inside a struct', () => {
@@ -125,9 +132,8 @@ describe('a field of a struct declared inside a struct', () => {
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } });
});
it('opens a list field in the List tab by its path', () => {
handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!();
const list = resolveList(f.state, f.state.listSlot!)!;
it('draws a list field inline by its path', () => {
const list = handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).list!()!;
expect(list.title).toBe('B · enemy · loot · drops');
list.replace(['a', ['b']]);
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } });
+141
View File
@@ -0,0 +1,141 @@
/**
* The rules behind the Variables tab (#252): which document a target is – the same list asked for
* twice is one tab, a list inside it another – what the tab says, and how much of a list the
* inspector draws.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, RenameSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot, type ValuesTarget } from '../../src/editor/state';
import { deeperSlot, INLINE_LIMIT, shownItems, valuesKey } from '../../src/editor/variables/target';
import { valuesTitle } from '../../src/editor/variables/VariablesEditor';
function setup() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'Pickup');
expect(state.execute(create)).toBe(true);
const id = create.asset.id;
expect(state.execute(new AddVariableCommand(id, 'loots', 'list'))).toBe(true);
return { state, id };
}
describe('the document of a Variables tab', () => {
const slot: ListSlot = { kind: 'asset', assetId: 'a', key: 'loots' };
it('is the same for the same list, however the address was built', () => {
const built = { key: 'loots', assetId: 'a', kind: 'asset', path: [] } as const;
expect(valuesKey({ kind: 'list', slot })).toBe(valuesKey({ kind: 'list', slot: built }));
expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) })).toBe(
valuesKey({ kind: 'list', slot: { ...slot, path: [2] } }),
);
});
it('is another one for a list inside it, so opening that one adds a tab', () => {
const outer = valuesKey({ kind: 'list', slot });
const inner = valuesKey({ kind: 'list', slot: deeperSlot(slot, [2, 'drops']) });
expect(inner).not.toBe(outer);
// A key and an index that read alike are still two steps.
expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, ['2']) })).not.toBe(
valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) }),
);
});
it('keeps the variables of an owner apart from a list of it and from another owner', () => {
const asset: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: 'a' } };
const object: ValuesTarget = {
kind: 'variables',
owner: { kind: 'object', holderId: 's', objectId: 'a' },
};
const keys = new Set([asset, object, { kind: 'list', slot } as const].map(valuesKey));
expect(keys.size).toBe(3);
});
});
describe('how much of a list the inspector draws', () => {
it('draws everything up to the limit and counts the rest', () => {
expect(shownItems(5, INLINE_LIMIT)).toEqual({ shown: 5, more: 0 });
expect(shownItems(INLINE_LIMIT, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 0 });
expect(shownItems(23, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 3 });
});
it('draws all of it without a limit – the tab', () => {
expect(shownItems(500)).toEqual({ shown: 500, more: 0 });
});
});
describe('what a Variables tab says', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => (f = setup()));
it('names the asset, the placed object and the list', () => {
expect(valuesTitle(f.state, { kind: 'variables', owner: { kind: 'asset', assetId: f.id } })).toBe(
'Pickup · Variables',
);
const createScene = new CreateAssetCommand('scene', 'Level');
expect(f.state.execute(createScene)).toBe(true);
const scene = createScene.asset.id;
const place = new PlaceAssetCommand(scene, f.id, { x: 0, y: 0 });
expect(f.state.execute(place)).toBe(true);
const object: ValuesTarget = {
kind: 'variables',
owner: { kind: 'object', holderId: scene, objectId: place.objectId },
};
expect(valuesTitle(f.state, object)).toBe('Pickup · Variables');
expect(f.state.execute(new RenameSceneObjectCommand(scene, place.objectId, 'Coin 3'))).toBe(true);
expect(valuesTitle(f.state, object)).toBe('Coin 3 · Variables');
const slot: ListSlot = { kind: 'asset', assetId: f.id, key: 'loots' };
expect(valuesTitle(f.state, { kind: 'list', slot })).toBe('Pickup · loots');
});
it('says nothing once the owner is gone – the tab closes then', () => {
const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: f.id } };
const list: ValuesTarget = { kind: 'list', slot: { kind: 'asset', assetId: f.id, key: 'loots' } };
expect(f.state.execute(new DeleteAssetCommand(f.id))).toBe(true);
expect(valuesTitle(f.state, target)).toBe(null);
expect(valuesTitle(f.state, list)).toBe(null);
});
it('has no Variables tab for an asset that declares none', () => {
const texture = new CreateAssetCommand('texture', 'T');
expect(f.state.execute(texture)).toBe(true);
const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: texture.asset.id } };
expect(valuesTitle(f.state, target)).toBe(null);
});
});
describe('opening a list in a tab', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => (f = setup()));
it('asks for a tab on the list itself, or on one inside it', () => {
const list = resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'loots' })!;
list.replace([[1, 2]]);
let asked = 0;
f.state.on('values', () => (asked += 1));
list.openList!([]);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [] },
});
list.openList!([0]);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [0] },
});
expect(asked).toBe(2);
});
it('offers no tab for a list a node holds inline', () => {
const add = new AddNodeCommand(f.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node = resolveList(f.state, { kind: 'node', assetId: f.id, nodeId: add.node.id, key: 'value' })!;
expect(node.openList).toBeUndefined();
node.replace([[1]]);
expect(node.sub([0])!.openList).toBeUndefined();
});
});