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
@@ -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*.
|
||||
*/
|
||||
|
||||
@@ -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']] } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user