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

+306 -145
View File
@@ -1,12 +1,14 @@
/**
* 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.
* Lists inline (#252): a list's items stand right under its row in the inspector, a row per item
* edited with the inspector's own widgets, and a list inside a list is drawn inline again. The head
* of a list opens it in a Variables tab – a list opened from inside such a tab gets a tab of its own
* beside it – and the VARIABLES header opens every variable of an asset or a placed object there.
*/
import { clickDocumentTab, MAKE_OBJECT } from './lib/scaffold.mjs';
export async function listTab(t) {
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
/**
* A kind menu entry, always the *last* drawn one: a menu is drawn on top, and the kind names are
* ordinary words ("Text") that the panels behind it may carry too – which is what made this click
@@ -17,6 +19,21 @@ export async function listTab(t) {
await click(at.x, at.y);
};
/** The hooks of one inline list, found by its title in the inspector or in the Variables tab. */
const hook = (fn, where, title, ...args) =>
json(`__gm.values.${fn}(${[where, title, ...args].map((a) => JSON.stringify(a)).join(', ')})`);
const listInfo = async (title, where = 'inspector') =>
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === title) ?? null;
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
};
const valueDocs = async () => (await json('__gm.documents()')).docs.filter((d) => d.startsWith('values:'));
const closeValueDocs = async () => {
for (const doc of await valueDocs()) await mutate(`__gm.closeDocument(${JSON.stringify(doc)})`);
};
const NUMS = 'Listy · nums';
// 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(`
@@ -32,41 +49,35 @@ export async function listTab(t) {
__gm.state.select({ kind: 'asset', assetId: 'listy' });
`);
// 21a. 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));
// 21a. The inspector shows the count and, under it, the items themselves.
check('a list property reads as its item count', (await find('3 items')) !== null);
let info = await listInfo(NUMS);
check(
'and its items stand right under it, a row each',
info?.rows === 3 && JSON.stringify(info.items) === '["30","11","44"]',
JSON.stringify(info),
);
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
await shot('01-count-button');
await click(button.x, button.y);
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');
await shot('01-inline');
// 21b. 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 press_(await hook('fieldAt', 'inspector', NUMS, 1), 'item 1');
await typeText('42');
await press('Enter');
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
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()');
info = await listInfo(NUMS);
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
// 21b-ii. An item edit does not rebuild the rows, so a delete clicked after it must build on the
// edited list – with the one the rows were built from, the edit came back undone (#235).
const edited = await json('__gm.list.fieldAt(2)');
await click(edited.x, edited.y);
await press_(await hook('fieldAt', 'inspector', NUMS, 2), 'item 2');
await typeText('66');
await press('Enter');
const removeFirst = await json('__gm.list.removeAt(0)');
await click(removeFirst.x, removeFirst.y);
await press_(await hook('removeAt', 'inspector', NUMS, 0), 'remove 0');
await sleep(200);
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
check(
'deleting a row keeps an edit made to another row before',
JSON.stringify(info.items) === '["11","66"]',
@@ -74,47 +85,75 @@ export async function listTab(t) {
);
await undo();
await undo();
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
check(
'and two undos take delete and edit back',
JSON.stringify(info.items) === '["30","11","44"]',
info.items,
);
// 21c. 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.
// 21c. A long list: the inspector draws the first 20 and says how many more there are; that line
// opens the whole list in a tab.
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();
`);
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 sleep(150);
info = await listInfo(NUMS);
const longRows = [await hook('fieldAt', 'inspector', NUMS, 0), await hook('fieldAt', 'inspector', NUMS, 1)];
check(
'a long list draws 20 rows in the inspector and counts the other 40',
info.rows === 20 && info.more === 40 && longRows[0].y !== longRows[1].y,
JSON.stringify({ rows: info.rows, more: info.more, longRows }),
);
await shot('02-more');
// The line sits below the inspector's fold: scrolled to with the wheel, as a user gets there.
await t.wheel(longRows[0].x - 140, longRows[0].y, 2000);
await sleep(150);
await press_(await hook('moreAt', 'inspector', NUMS), '… more');
await sleep(200);
const tabList = await listInfo(NUMS, 'tab');
check(
'clicking "… 40 more" opens the list in a Variables tab, every item drawn',
(await json('__gm.tab()')) === 'variables' && tabList?.rows === 60 && tabList.more === 0,
JSON.stringify({ tab: await json('__gm.tab()'), tabList }),
);
check(
'titled by the list, the inspector still beside it',
(await json('__gm.values.info()')).title === NUMS && (await find('VARIABLES')) !== null,
(await json('__gm.values.info()')).title,
);
await shot('03-tab');
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
a.props.nums = [30, 11, 44];
__gm.state.preview();
`);
const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')];
await sleep(150);
const shortRows = [await hook('fieldAt', 'tab', NUMS, 0), await hook('fieldAt', 'tab', NUMS, 1)];
check(
'and a short list after it still stacks its own',
shortRows[0].y !== shortRows[1].y,
'and a short list after it still stacks its rows',
(await listInfo(NUMS, 'tab')).rows === 3 && shortRows[0].y !== shortRows[1].y,
JSON.stringify(shortRows),
);
await closeValueDocs();
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
await clickText('+ Item');
const addTo = async (title, where = 'inspector') =>
press_((await hook('headAt', where, title))?.add, '+ Item');
await addTo(NUMS);
check(
'a list of its own offers every kind, list and struct included',
(await json('__gm.list.info()')).offers.join(',') ===
(await listInfo(NUMS)).offers.join(',') ===
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
// and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239.
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct',
JSON.stringify((await json('__gm.list.info()')).offers),
JSON.stringify((await listInfo(NUMS)).offers),
);
await pickKind('Text');
await sleep(200);
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
check('+ Item appends one of the kind picked', info.items.length === 4, JSON.stringify(info.items));
check(
'and that row holds that kind',
@@ -122,77 +161,108 @@ export async function listTab(t) {
info.kinds.join(','),
);
await undo();
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
await sleep(150);
check('undo removes it again', (await listInfo(NUMS)).items.length === 3);
// 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs.
await clickText('+ Item');
await addTo(NUMS);
await pickKind('Object');
await sleep(200);
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
check(
'an Object item is added as the empty object',
info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '',
JSON.stringify(info),
);
await shot('03a-object-item');
await undo();
await sleep(150);
// 21d-iii. An item may be a list again (#239): its row is an item count, and a click opens the
// List tab on that list, one level down, with the path in the title.
// 21d-iii. An item may be a list again (#239): it is drawn inline under its row, one step in, the
// path in its title – and opened from a tab, it gets a tab of its own beside that one.
const INNER = `${NUMS} · [3]`;
const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums");
await clickText('+ Item');
await addTo(NUMS);
await pickKind('List');
await sleep(200);
info = await json('__gm.list.info()');
info = await listInfo(NUMS);
check(
'a List item is added as an empty list',
info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]',
JSON.stringify(info),
);
const innerCount = await json('__gm.list.fieldAt(3)');
await click(innerCount.x, innerCount.y);
await sleep(200);
info = await json('__gm.list.info()');
check(
'clicking its count opens that list, the path in the title',
info.title.endsWith('· nums · [3]') && info.items.length === 0,
JSON.stringify(info),
);
await clickText('+ Item');
check('and drawn inline, the path in its title', (await listInfo(INNER))?.rows === 0);
await addTo(INNER);
await pickKind('Number');
await sleep(200);
check(
'an item added there lands in the list inside the list',
JSON.stringify((await nums()).slice(3)) === '[[0]]',
JSON.stringify((await nums()).slice(3)) === '[[0]]' && (await listInfo(INNER))?.rows === 1,
JSON.stringify(await nums()),
);
await shot('03b-nested-list');
await undo();
await undo();
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
await shot('03-rows');
// 21e. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)');
const outerRow = await hook('fieldAt', 'inspector', NUMS, 2);
const innerRow = await hook('fieldAt', 'inspector', INNER, 0);
check(
'closing the list closes its tab',
(await json('__gm.tab()')) !== 'list' && !(await json('__gm.documents()')).docs.includes('list'),
JSON.stringify({ tab: await json('__gm.tab()'), docs: await json('__gm.documents()') }),
'its row sits below the outer rows, pushed further right',
innerRow.y > outerRow.y && innerRow.x > outerRow.x - 1,
JSON.stringify({ outerRow, innerRow }),
);
await shot('04-nested-inline');
await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
await sleep(200);
const outerDoc = (await json('__gm.documents()')).active;
check(
'Open on the head opens the list in a tab, with the inner list inline in it',
(await json('__gm.values.info()')).title === NUMS && (await listInfo(INNER, 'tab'))?.rows === 1,
JSON.stringify(await json('__gm.values.info()')),
);
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list');
await sleep(200);
let docs = await json('__gm.documents()');
check(
'Open on a list inside the tab opens a second tab and leaves the first',
(await valueDocs()).length === 2 &&
docs.docs.includes(outerDoc) &&
docs.active !== outerDoc &&
(await json('__gm.values.info()')).title === INNER,
JSON.stringify({ docs, title: (await json('__gm.values.info()')).title }),
);
await shot('05-second-tab');
await clickDocumentTab(t, outerDoc);
await sleep(150);
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list, again');
await sleep(150);
docs = await json('__gm.documents()');
check(
'asking again for a list that has its tab brings that tab up, no third one',
(await valueDocs()).length === 2 && (await json('__gm.values.info()')).title === INNER,
JSON.stringify(docs),
);
await undo();
await undo();
await sleep(150);
check(
'undo empties the inner list first, then takes it away – and its tab with it',
(await valueDocs()).length === 1 && JSON.stringify(await nums()) === '[30,11,44]',
JSON.stringify({ docs: await valueDocs(), nums: await nums() }),
);
await closeValueDocs();
// 21e. The count follows the value.
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
__gm.state.preview();
`);
check('and the count follows the value', (await find('2 items')) !== null);
await sleep(150);
check(
'the count and the rows follow the value',
(await find('2 items')) !== null && (await listInfo(NUMS)).rows === 2,
);
// 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
// it is the picker, and picking changes that one item and no other.
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
let mark = await json('__gm.list.markAt(0)');
await click(mark.x, mark.y);
await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
await pickKind('Text');
await sleep(250);
let listy = await numsOf();
@@ -202,9 +272,9 @@ export async function listTab(t) {
JSON.stringify(listy.props.nums),
);
check(
'and the tab reads the two kinds off the values',
(await json('__gm.list.info()')).kinds.join(',') === 'text,number',
JSON.stringify((await json('__gm.list.info()')).kinds),
'and the list reads the two kinds off the values',
(await listInfo(NUMS)).kinds.join(',') === 'text,number',
JSON.stringify((await listInfo(NUMS)).kinds),
);
check('the variable itself says nothing about items', listy.variables[0].items === undefined);
await undo();
@@ -216,8 +286,8 @@ export async function listTab(t) {
);
// 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
mark = await json('__gm.list.markAt(1)');
await click(mark.x, mark.y);
await sleep(150);
await press_(await hook('markAt', 'inspector', NUMS, 1), 'mark 1');
await pickKind('Color');
await sleep(250);
listy = await numsOf();
@@ -228,11 +298,12 @@ export async function listTab(t) {
);
check(
'and is read back as a colour, its neighbour still a number',
(await json('__gm.list.info()')).kinds.join(',') === 'number,color',
JSON.stringify((await json('__gm.list.info()')).kinds),
(await listInfo(NUMS)).kinds.join(',') === 'number,color',
JSON.stringify((await listInfo(NUMS)).kinds),
);
await shot('03b-kinds');
await shot('06-kinds');
await undo();
await sleep(150);
// 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
// The row carries the picker the `texture` property has, frames of a sheet included.
@@ -250,8 +321,7 @@ export async function listTab(t) {
});
__gm.state.preview();
`);
mark = await json('__gm.list.markAt(0)');
await click(mark.x, mark.y);
await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
await pickKind('Texture');
await sleep(250);
// Switching kind carries the value along, here into an id that resolves to nothing – the same as
@@ -261,8 +331,7 @@ export async function listTab(t) {
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]',
JSON.stringify((await numsOf()).props.nums),
);
const pic = await json('__gm.list.fieldAt(0)');
await click(pic.x, pic.y);
await press_(await hook('fieldAt', 'inspector', NUMS, 0), 'picture 0');
check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null);
await pickKind('Sheet / Frame 1');
await sleep(250);
@@ -272,11 +341,10 @@ export async function listTab(t) {
JSON.stringify((await numsOf()).props.nums),
);
check(
'which the tab reads back as a texture, its neighbour still a number',
(await json('__gm.list.info()')).kinds.join(',') === 'texture,number',
JSON.stringify((await json('__gm.list.info()')).kinds),
'which the list reads back as a texture, its neighbour still a number',
(await listInfo(NUMS)).kinds.join(',') === 'texture,number',
JSON.stringify((await listInfo(NUMS)).kinds),
);
await shot('03c-texture');
await undo();
await undo();
check(
@@ -291,8 +359,7 @@ export async function listTab(t) {
__gm.state.preview();
`);
await mutate('__gm.state.openList(null)');
// The mark of a list is plain now, and still may not push the value off the inspector's grid.
// The mark of a list is plain, and still may not push the value off the inspector's grid.
const lefts = await json(
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`,
);
@@ -302,21 +369,26 @@ export async function listTab(t) {
JSON.stringify(lefts),
);
// 21i. 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' })");
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
// 21i. A list whose owner is gone takes its tab along rather than showing a stale one.
await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
await sleep(150);
check('the list is open in a tab', (await valueDocs()).length === 1);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'listy');
__gm.state.select(null);
__gm.state.preview();
`);
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');
await sleep(150);
check(
'a deleted owner closes the tab',
(await valueDocs()).length === 0 && (await json('__gm.tab()')) !== 'variables',
JSON.stringify({ docs: await valueDocs(), tab: await json('__gm.tab()') }),
);
// 21j. 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.
// 21j. A list whose items are structs: each row is the bundle's fields under each other. Values
// are two digits so no row's index can match one.
const CELLS = 'Celly · cells';
await mutate(`
const p = __gm.state.project;
p.assets.push({
@@ -328,54 +400,50 @@ export async function listTab(t) {
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`);
info = await json('__gm.list.info()');
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info));
await sleep(150);
info = await listInfo(CELLS);
check(
'a list of structs draws a row per bundle',
info?.items.length === 2 && info.rows === 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 reads both items as structs',
info.kinds.join(',') === 'struct,struct',
info.kinds.join(','),
);
check('both items read as structs', info.kinds.join(',') === 'struct,struct', info.kinds.join(','));
check('the field labels of a bundle are on screen', (await find('x')) !== null);
await shot('05-struct-rows');
await shot('07-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 press_(await hook('fieldAt', 'inspector', CELLS, 0, 'x'), 'field x');
await typeText('77');
await press('Enter');
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
const cellsOf = () => 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(await cellsOf()) ===
JSON.stringify([
{ x: 77, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(cells),
JSON.stringify(await cellsOf()),
);
await undo();
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(await cellsOf()) ===
JSON.stringify([
{ x: 30, y: 11 },
{ x: 44, y: 55 },
]),
JSON.stringify(back),
JSON.stringify(await cellsOf()),
);
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – and
// primitives only, because a list inside a list item is the one shape the model does not store.
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row.
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
await clickText('+ Field');
await pickKind('Boolean');
@@ -389,22 +457,16 @@ export async function listTab(t) {
]),
JSON.stringify(await cellsOf()),
);
info = await json('__gm.list.info()');
check(
'and the tab shows the new field on that row',
info.items[0] === 'x: 30, y: 11, field1: false',
info.items[0],
);
info = await listInfo(CELLS);
check('and the row shows the new field', info.items[0] === 'x: 30, y: 11, field1: false', info.items[0]);
// Asked in the page: `undefined` comes back over the wire as `null`.
check(
'the inspector has no item shape to show any more',
'the variable has no item shape to show',
await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`),
);
await shot('06-item-field');
// A field of a struct item is renamed and retyped where it sits, both being plain value edits.
const nameBox = await json("__gm.list.nameAt(0, 'y')");
await click(nameBox.x, nameBox.y);
await press_(await hook('nameAt', 'inspector', CELLS, 0, 'y'), 'name y');
await press('a', { ctrl: true });
await typeText('row');
await press('Enter');
@@ -417,8 +479,8 @@ export async function listTab(t) {
]),
JSON.stringify(await cellsOf()),
);
const fieldMark = await json("__gm.list.markAt(0, 'x')");
await click(fieldMark.x, fieldMark.y);
await sleep(150);
await press_(await hook('markAt', 'inspector', CELLS, 0, 'x'), 'mark x');
await pickKind('Color');
await sleep(250);
check(
@@ -439,8 +501,8 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()),
);
// 21l. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// button too.
// 21l. A node's unconnected `list` input lives in the same params bag as a setting: inline only,
// no tab to open it in.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
@@ -448,22 +510,22 @@ export async function listTab(t) {
__gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] });
__gm.state.preview();
`);
const portButton = await find('0 items');
check('an unconnected list input reads as an item count', portButton !== null);
await click(portButton.x, portButton.y);
check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list');
info = await json('__gm.list.info()');
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title);
await clickText('+ Item');
await sleep(150);
const portHead = await hook('headAt', 'inspector', 'Count · list');
check(
'an unconnected list input is drawn inline, without an Open',
(await find('0 items')) !== null && portHead !== null && portHead.open === null,
JSON.stringify(portHead),
);
await press_(portHead.add, '+ Item of the port');
await pickKind('Number');
await sleep(250);
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
check(
'and the item lands in the node params, where the node reads it',
JSON.stringify(params) === '{"list":[0]}',
'and an item added there lands in the node params, where the node reads it',
JSON.stringify(params) === '{"list":[0]}' && (await listInfo('Count · list'))?.rows === 1,
JSON.stringify(params),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly');
@@ -472,9 +534,108 @@ export async function listTab(t) {
__gm.state.preview();
`);
await mutate('__gm.state.openList(null)');
// 21m. The Variables tab: every variable of a behavior, with the inspector's rows – the list all
// of it, where the inspector stops at 20.
const HITS = 'Varsy · hits';
// The inspector's header is the one with an Open beside it: an open Variables tab heads its rows
// with the same word, without one.
const openVariables = async () => {
const headers = await json(`__gm.locateAll('VARIABLES')`);
const opens = await json(`__gm.locateAll('Open')`);
const open = opens.find((p) => headers.some((h) => Math.abs(p.y - h.y) < 8 && p.x > h.x));
await press_(open, 'Open of VARIABLES');
await sleep(200);
};
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'varsy',
type: 'behavior',
name: 'Varsy',
props: { enabled: true, runInEditor: false, hp: 5, hits: Array.from({ length: 25 }, (_, i) => 10 + i) },
variables: [
{ key: 'hp', label: 'hp', kind: 'number', default: 0 },
{ key: 'hits', label: 'hits', kind: 'list', default: [] },
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'varsy' });
`);
await sleep(150);
await openVariables();
let tab = await json('__gm.values.info()');
check(
'Open in the VARIABLES header opens a Variables tab on the behavior',
(await json('__gm.tab()')) === 'variables' &&
tab.title === 'Varsy · Variables' &&
tab.rows.join(',') === 'hp,hits',
JSON.stringify(tab),
);
check(
'where the list shows all 25 items, the inspector 20 of them',
(await listInfo(HITS, 'tab'))?.rows === 25 && (await listInfo(HITS))?.rows === 20,
JSON.stringify({ tab: await listInfo(HITS, 'tab'), inspector: await listInfo(HITS) }),
);
await shot('08-variables-tab');
await press_(await hook('fieldAt', 'tab', HITS, 0), 'hits 0 in the tab');
await typeText('99');
await press('Enter');
await sleep(150);
check(
'an item typed in the tab is in the asset and in the inspector',
(await json(`__gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[0]`)) === 99 &&
(await listInfo(HITS)).items[0] === '99',
JSON.stringify((await listInfo(HITS)).items.slice(0, 2)),
);
await undo();
// 21n. The same for a placed behavior: its tab edits the overrides of that object, not the asset.
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
scene.objects.push(make('varsy-1', 'varsy', '', 0, 'Varsy One'));
__gm.state.select({ kind: 'object', holderId: scene.id, objectId: 'varsy-1', objectIds: ['varsy-1'] });
__gm.state.preview();
`);
await sleep(200);
await openVariables();
tab = await json('__gm.values.info()');
check(
'a placed behavior opens a tab of its own, named after the object',
tab.title === 'Varsy One · Variables' && (await valueDocs()).length === 2,
JSON.stringify({ tab, docs: await valueDocs() }),
);
await press_(await hook('fieldAt', 'tab', 'Varsy One · hits', 1), 'hits 1 of the object');
await typeText('77');
await press('Enter');
await sleep(150);
const placed = await json(`({
override: __gm.state.activeScene.objects.find((o) => o.id === 'varsy-1').overrides.hits?.[1] ?? null,
asset: __gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[1],
})`);
check(
'an item typed there overrides the object and leaves the asset alone',
placed.override === 77 && placed.asset === 11,
JSON.stringify(placed),
);
await shot('09-object-tab');
await undo();
await closeValueDocs();
await mutate(`
const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'varsy-1');
p.assets = p.assets.filter((a) => a.id !== 'varsy');
__gm.state.select(null);
__gm.showTab('scene');
__gm.state.preview();
`);
const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length',
'__gm.state.project.assets.filter((a) => ["listy", "celly", "varsy"].includes(a.id)).length',
);
check(
'the step leaves the fixture as it found it',
left === 0 && (await valueDocs()).length === 0,
String(left),
);
check('the step leaves the fixture as it found it', left === 0, String(left));
}
+18 -16
View File
@@ -120,10 +120,10 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
// 22f. A field may itself be a list. Its row is the same "n items" button a list property has,
// and it opens the List tab one level down – on the field, not on the whole bundle.
// 22f. A field may itself be a list. Its items are drawn inline under it (#252), and its
// title names the field inside the bundle – not the whole bundle.
await clickText('+ Field');
// "List" is also the tab bar's own label, so the menu entry is reached as the last drawn one.
// "List" is also a word elsewhere on screen, so the menu entry is reached as the last drawn one.
const listOption = await json(`__gm.locate('List', true)`);
await click(listOption.x, listOption.y);
check(
@@ -131,28 +131,32 @@ export async function structVariable(t) {
Array.isArray((await piece()).field1),
JSON.stringify(await piece()),
);
const itemsButton = await find('0 items');
check('and that field reads as an item count', itemsButton !== null);
await click(itemsButton.x, itemsButton.y);
check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list');
const info = await json('__gm.list.info()');
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
check('and that field reads as an item count', (await find('0 items')) !== null);
const fieldList = async () =>
(await json(`__gm.values.lists('inspector')`)).find((l) => l.title.endsWith('· field1')) ?? null;
const drawn = await fieldList();
check(
'whose items are drawn inline, titled by the field inside the bundle',
drawn !== null,
JSON.stringify(drawn),
);
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
await clickText('+ Item');
const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(drawn?.title ?? '')})`);
await click(head.add.x, head.add.y);
check(
'the menu of a list in a struct field offers every kind, the struct included',
(await find('Number')) !== null &&
(await find('Struct')) !== null &&
(await json('__gm.list.info()')).offers.includes('list'),
JSON.stringify((await json('__gm.list.info()')).offers),
(await fieldList()).offers.includes('list'),
JSON.stringify((await fieldList()).offers),
);
await pickKind('Number');
await sleep(250);
check(
'and an item added there leaves the neighbouring fields alone',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}' &&
(await fieldList()).rows === 1,
JSON.stringify(await piece()),
);
await shot('03-list-field');
@@ -164,8 +168,6 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
await mutate('__gm.state.openList(null)');
// 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its
// fields one indent step further in. Both menus with real clicks.
const declaredLast = () =>
+20 -15
View File
@@ -104,8 +104,9 @@ export async function scope(t) {
);
await stop();
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the List tab it
// opens has to show those too and follow them – not the empty list the document declares.
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the items drawn
// under it and in the Variables tab it opens have to be those too, and follow them – not
// the empty list the document declares.
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
@@ -128,6 +129,8 @@ export async function scope(t) {
${MAKE_OBJECT}
scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler'));
`);
const bagIn = async (where) =>
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === 'Bag · bag') ?? null;
// Held, so the count the row shows stands still long enough to be found and clicked.
await t.play();
await sleep(300);
@@ -137,37 +140,39 @@ export async function scope(t) {
await selectAsset('sc-bag');
await sleep(200);
const countRow = await t.find(`${held.length} items`);
if (countRow) await click(countRow.x, countRow.y);
const inline = await bagIn('inspector');
const head = await json(`__gm.values.headAt('inspector', 'Bag · bag')`);
if (head?.open) await click(head.open.x, head.open.y);
await sleep(150);
const first = await json('__gm.list.info()');
const first = await bagIn('tab');
await shot('01b-live-list');
await t.clickToolbar('pause');
await sleep(500);
const later = await json('__gm.list.info()');
const later = await bagIn('tab');
await stop();
await sleep(200);
const stopped = await json('__gm.list.info()');
const stopped = await bagIn('tab');
check(
'while playing, the List tab opened from a shared list shows the items the run put in',
'while playing, a shared list shows the items the run put in – inline and in its tab',
held.length > 0 &&
Boolean(countRow) &&
(await json('__gm.tab()')) === 'list' &&
first.title === 'Bag · bag' &&
first.rows === held.length &&
inline?.rows === Math.min(held.length, 20) &&
(await json('__gm.tab()')) === 'variables' &&
first?.rows === held.length &&
first.items.every((item) => item === '7'),
JSON.stringify({ run: held.length, row: countRow, title: first.title, rows: first.rows }),
JSON.stringify({ run: held.length, row: countRow, inline: inline?.rows, tab: first?.rows }),
);
check(
'and follows the list as it grows, then shows the declared list again once the run stops',
later.rows > first.rows && stopped.rows === 0 && stopped.items.length === 0,
JSON.stringify({ first: first.rows, later: later.rows, stopped: stopped.rows }),
'and the tab follows the list as it grows, then shows the declared list again once the run stops',
later?.rows > first?.rows && stopped?.rows === 0 && stopped.items.length === 0,
JSON.stringify({ first: first?.rows, later: later?.rows, stopped: stopped?.rows }),
);
await mutate(`
const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f');
p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag');
__gm.state.openList(null);
__gm.state.select(null);
__gm.state.preview();
`);
// 32c. The asset is where the declaration lives, and it says what it is for.
+3 -3
View File
@@ -170,12 +170,12 @@ const DOCUMENT_KINDS = [
'curve',
'tile',
'container',
'list',
'variables',
'management',
];
/**
* Clicks the tab of an open document (`asset:<id>` or `list`), through the `»` menu when the row
* Clicks the tab of an open document (`asset:<id>` or `values:<key>`), through the `»` menu when the row
* hides it. The press lands left of the ×, on the name.
*/
export async function clickDocumentTab(t, doc) {
@@ -188,7 +188,7 @@ export async function clickDocumentTab(t, doc) {
if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`);
const label = await t.json(`(() => {
const id = ${JSON.stringify(doc)};
if (id === 'list') return null;
if (id.startsWith('values:')) return __gm.tabRow().labels?.[id] ?? null;
const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id);
return asset ? asset.name : null;
})()`);