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>
142 lines
6.4 KiB
TypeScript
142 lines
6.4 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
|
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
|
import type { PropertyValue } from '../../src/core/model/Properties';
|
|
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
|
import { requirePropDef } from '../../src/core/commands/require';
|
|
import { structFieldsOf } from '../../src/editor/inspector/previewHandlers';
|
|
import { resolveList } from '../../src/editor/list/slot';
|
|
import { EditorState, type ListSlot } from '../../src/editor/state';
|
|
|
|
function setup(): { state: EditorState; id: string } {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const create = new CreateAssetCommand('behavior', 'B');
|
|
expect(state.execute(create)).toBe(true);
|
|
return { state, id: create.asset.id };
|
|
}
|
|
|
|
const props = (f: ReturnType<typeof setup>) => (findAsset(f.state.project, f.id) as BehaviorAsset).props;
|
|
|
|
describe('a list nested in the items of a list', () => {
|
|
let f: ReturnType<typeof setup>;
|
|
const rows = [{ name: 'a', drops: [1, { x: [2, 3] }] }, 'plain'];
|
|
beforeEach(() => {
|
|
f = setup();
|
|
expect(f.state.execute(new AddVariableCommand(f.id, 'rows', 'list'))).toBe(true);
|
|
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows' })!.replace(rows);
|
|
});
|
|
|
|
const at = (path: ListSlot['path']) =>
|
|
resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows', path });
|
|
|
|
it('is found by its path, and the title spells the path out', () => {
|
|
expect(at([0, 'drops'])!.value).toEqual([1, { x: [2, 3] }]);
|
|
expect(at([0, 'drops'])!.title).toBe('B · rows · [0] · drops');
|
|
expect(at([0, 'drops', 1, 'x'])!.value).toEqual([2, 3]);
|
|
expect(at([0, 'drops', 1, 'x'])!.title).toBe('B · rows · [0] · drops · [1] · x');
|
|
});
|
|
|
|
it('is nothing where the path leads to no list', () => {
|
|
expect(at([0, 'name'])).toBe(null);
|
|
expect(at([1, 'drops'])).toBe(null);
|
|
expect(at([5])).toBe(null);
|
|
});
|
|
|
|
it('writes one deep item as one command on the whole list, and undo takes back just that', () => {
|
|
at([0, 'drops', 1, 'x'])!.itemHandlers(1).onChange(9);
|
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
|
|
at([0, 'drops'])!.replace([1, { x: [2, 9] }, [4]]);
|
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }, [4]] }, 'plain']);
|
|
|
|
f.state.commands.undo();
|
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']);
|
|
f.state.commands.undo();
|
|
expect(props(f).rows).toEqual(rows);
|
|
});
|
|
|
|
it('edits a value inside an item by its path, keeping the kind it has', () => {
|
|
const list = at([])!;
|
|
list.handlersAt([0, 'drops', 1, 'x', 0]).onChange('7');
|
|
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [7, 3] }] }, 'plain']);
|
|
});
|
|
|
|
it('previews a deep edit and still undoes to before the preview', () => {
|
|
const handlers = at([])!.handlersAt([0, 'name']);
|
|
handlers.onInput('ab');
|
|
handlers.onInput('abc');
|
|
handlers.onChange('abc');
|
|
expect(props(f).rows).toEqual([{ name: 'abc', drops: [1, { x: [2, 3] }] }, 'plain']);
|
|
f.state.commands.undo();
|
|
expect(props(f).rows).toEqual(rows);
|
|
});
|
|
|
|
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', () => {
|
|
let f: ReturnType<typeof setup>;
|
|
beforeEach(() => {
|
|
f = setup();
|
|
const run = (command: Parameters<EditorState['execute']>[0]) =>
|
|
expect(f.state.execute(command)).toBe(true);
|
|
run(new AddVariableCommand(f.id, 'enemy', 'struct'));
|
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'hp', kind: 'number' }));
|
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'loot', kind: 'struct', fields: [] }));
|
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'count', kind: 'number' }, ['loot']));
|
|
run(new AddStructFieldCommand(f.id, 'enemy', { key: 'drops', kind: 'list' }, ['loot']));
|
|
run(new SetAssetPropertyCommand(f.id, 'enemy', { hp: 5, loot: { count: 1, drops: ['a'] } }));
|
|
});
|
|
|
|
const handlers = () => {
|
|
const def = requirePropDef(f.state.project, findAsset(f.state.project, f.id)!, 'enemy');
|
|
if (def.kind !== 'struct') throw new Error('enemy is not a struct');
|
|
return structFieldsOf<{ props: Record<string, PropertyValue> }>(
|
|
{ state: f.state },
|
|
def,
|
|
{
|
|
target: () => findAsset(f.state.project, f.id),
|
|
read: (a) => a.props.enemy,
|
|
write: (a, value) => (a.props.enemy = value),
|
|
commit: (value) => new SetAssetPropertyCommand(f.id, 'enemy', value),
|
|
},
|
|
(path) => ({ kind: 'asset', assetId: f.id, key: 'enemy', path }),
|
|
);
|
|
};
|
|
|
|
it('writes the nested field and leaves every other one alone, one undo step', () => {
|
|
handlers()(['loot', 'count'], { key: 'count', kind: 'number' }).onChange(4);
|
|
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 4, drops: ['a'] } });
|
|
f.state.commands.undo();
|
|
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } });
|
|
});
|
|
|
|
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']] } });
|
|
});
|
|
});
|