A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.
Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.
Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.
Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.7 KiB
JavaScript
194 lines
7.7 KiB
JavaScript
/**
|
||
* The List tab: a list property is a button saying how many items it has, clicking it opens the
|
||
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets.
|
||
*/
|
||
export async function listTab(t) {
|
||
const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t;
|
||
|
||
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
|
||
const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`);
|
||
|
||
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its
|
||
// items are two digits so that no value can be confused with a row's index label.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({
|
||
id: 'listy',
|
||
type: 'behavior',
|
||
name: 'Listy',
|
||
props: { enabled: true, runInEditor: false, nums: [30, 11, 44] },
|
||
variables: [{ key: 'nums', label: 'nums', kind: 'list', default: [], items: 'number' }],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'listy' });
|
||
`);
|
||
await sleep(150);
|
||
|
||
// 1. The inspector shows the count, not a comma-separated line.
|
||
const button = await find('3 items');
|
||
check('a list property reads as its item count', button !== null, JSON.stringify(button));
|
||
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
|
||
await shot('01-count-button');
|
||
|
||
// 2. Clicking it opens the List tab on that very list.
|
||
await click(button.x, button.y);
|
||
await sleep(200);
|
||
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
|
||
let info = await json('__gm.list.info()');
|
||
check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(info));
|
||
check('with a row per item', JSON.stringify(info.items) === '["30","11","44"]', JSON.stringify(info.items));
|
||
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
|
||
await shot('02-list-tab');
|
||
|
||
// 3. A row edits its item, and it is one undo step.
|
||
const second = await json('__gm.list.fieldAt(1)');
|
||
await click(second.x, second.y);
|
||
await typeText('42');
|
||
await press('Enter');
|
||
await sleep(200);
|
||
info = await json('__gm.list.info()');
|
||
check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]);
|
||
await undo();
|
||
info = await json('__gm.list.info()');
|
||
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
|
||
|
||
// 4. A short list opened after a long one. Its rows leave the content height pinned to the
|
||
// viewport, so a layout skipped on "same size" would stack them all at the top.
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
|
||
a.props.nums = Array.from({ length: 60 }, (_, i) => i);
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(250);
|
||
const longRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
|
||
check('a long list stacks its rows', longRows[0].y !== longRows[1].y, JSON.stringify(longRows));
|
||
await mutate(`
|
||
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
|
||
a.props.nums = [30, 11, 44];
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(250);
|
||
const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
|
||
check(
|
||
'and a short list after it still stacks its own',
|
||
shortRows[0].y !== shortRows[1].y,
|
||
JSON.stringify(shortRows),
|
||
);
|
||
|
||
// 5. Adding and removing whole items.
|
||
await clickText('+ Item');
|
||
await sleep(200);
|
||
info = await json('__gm.list.info()');
|
||
check('+ Item appends one', info.items.length === 4, JSON.stringify(info.items));
|
||
await undo();
|
||
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
|
||
await shot('03-rows');
|
||
|
||
// 6. Back on the scene tab the button carries the current count.
|
||
await mutate('__gm.state.openList(null)');
|
||
await sleep(200);
|
||
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
|
||
await mutate(`
|
||
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(200);
|
||
check('and the count follows the value', (await find('2 items')) !== null);
|
||
|
||
// 7. A list whose target is gone empties the tab rather than showing a stale one.
|
||
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||
await sleep(200);
|
||
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'listy');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(200);
|
||
info = await json('__gm.list.info()');
|
||
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
|
||
await shot('04-empty');
|
||
|
||
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
|
||
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets.push({
|
||
id: 'celly',
|
||
type: 'behavior',
|
||
name: 'Celly',
|
||
props: { enabled: true, runInEditor: false, cells: [{ x: 30, y: 11 }, { x: 44, y: 55 }] },
|
||
variables: [
|
||
{
|
||
key: 'cells',
|
||
label: 'cells',
|
||
kind: 'list',
|
||
default: [],
|
||
items: 'struct',
|
||
fields: [{ key: 'x', kind: 'number' }, { key: 'y', kind: 'number' }],
|
||
},
|
||
],
|
||
graph: { nodes: [], edges: [] },
|
||
});
|
||
__gm.state.select({ kind: 'asset', assetId: 'celly' });
|
||
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
|
||
`);
|
||
await sleep(250);
|
||
info = await json('__gm.list.info()');
|
||
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
|
||
check(
|
||
'and a row reads as its fields, not as an object',
|
||
info.items[0] === 'x: 30, y: 11',
|
||
JSON.stringify(info.items),
|
||
);
|
||
check('the tab knows its items are structs', info.kind === 'struct', info.kind);
|
||
check('the field labels of a bundle are on screen', (await find('x')) !== null);
|
||
await shot('05-struct-rows');
|
||
|
||
// Editing one field must leave the others and the neighbouring item alone – the whole item is
|
||
// rewritten on every keystroke, so a snapshot anywhere would silently revive an old value.
|
||
const firstField = await json("__gm.list.fieldAt(0, 'x')");
|
||
await click(firstField.x, firstField.y);
|
||
await typeText('77');
|
||
await press('Enter');
|
||
await sleep(250);
|
||
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
|
||
check(
|
||
'editing one field of one item leaves every other value untouched',
|
||
JSON.stringify(cells) ===
|
||
JSON.stringify([
|
||
{ x: 77, y: 11 },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(cells),
|
||
);
|
||
await undo();
|
||
await sleep(200);
|
||
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
|
||
check(
|
||
'and undo takes back just that field',
|
||
JSON.stringify(back) ===
|
||
JSON.stringify([
|
||
{ x: 30, y: 11 },
|
||
{ x: 44, y: 55 },
|
||
]),
|
||
JSON.stringify(back),
|
||
);
|
||
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
p.assets = p.assets.filter((a) => a.id !== 'celly');
|
||
__gm.state.select(null);
|
||
__gm.state.preview();
|
||
`);
|
||
await sleep(200);
|
||
|
||
await mutate('__gm.state.openList(null)');
|
||
await sleep(200);
|
||
const left = await json(
|
||
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length',
|
||
);
|
||
check('the step leaves the fixture as it found it', left === 0, String(left));
|
||
}
|