/** * 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; /** * 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 * land in the asset list once the whole suite ran and those panels had something in them. */ const pickKind = async (label) => { const at = await json(`__gm.locate(${JSON.stringify(label)}, true)`); 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(` 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: [] }], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'listy' }); `); // 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-inline'); // 21b. A row edits its item, and it is one undo step. await press_(await hook('fieldAt', 'inspector', NUMS, 1), 'item 1'); await typeText('42'); await press('Enter'); 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 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). await press_(await hook('fieldAt', 'inspector', NUMS, 2), 'item 2'); await typeText('66'); await press('Enter'); await press_(await hook('removeAt', 'inspector', NUMS, 0), 'remove 0'); await sleep(200); info = await listInfo(NUMS); check( 'deleting a row keeps an edit made to another row before', JSON.stringify(info.items) === '["11","66"]', JSON.stringify(info.items), ); await undo(); await undo(); info = await listInfo(NUMS); check( 'and two undos take delete and edit back', JSON.stringify(info.items) === '["30","11","44"]', info.items, ); // 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(); `); 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(); `); 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 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. 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 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 listInfo(NUMS)).offers), ); await pickKind('Text'); await sleep(200); 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', info.kinds.join(',') === 'number,number,number,text', info.kinds.join(','), ); await undo(); 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 addTo(NUMS); await pickKind('Object'); await sleep(200); 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 undo(); await sleep(150); // 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 addTo(NUMS); await pickKind('List'); await sleep(200); 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), ); 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]]' && (await listInfo(INNER))?.rows === 1, JSON.stringify(await nums()), ); const outerRow = await hook('fieldAt', 'inspector', NUMS, 2); const innerRow = await hook('fieldAt', 'inspector', INNER, 0); check( '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(); `); 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 press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0'); await pickKind('Text'); await sleep(250); let listy = await numsOf(); check( 'the row mark changes that item alone', JSON.stringify(listy.props.nums) === '["1",2]', JSON.stringify(listy.props.nums), ); check( '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(); listy = await numsOf(); check( 'undo puts the item back as a number', JSON.stringify(listy.props.nums) === '[1,2]', JSON.stringify(listy.props.nums), ); // 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged. await sleep(150); await press_(await hook('markAt', 'inspector', NUMS, 1), 'mark 1'); await pickKind('Color'); await sleep(250); listy = await numsOf(); check( 'a colour item keeps its number under a tag', JSON.stringify(listy.props.nums) === '[1,{"$color":2}]', JSON.stringify(listy.props.nums), ); check( 'and is read back as a colour, its neighbour still a number', (await listInfo(NUMS)).kinds.join(',') === 'number,color', JSON.stringify((await listInfo(NUMS)).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. // The pictures the earlier steps imported are set aside for this one: the menu lists every sheet // with all of its frames, and a long one would put the entry to click outside the drawn menu. await mutate(` const p = __gm.state.project; const isPicture = (a) => a.type === 'texture' || a.type === 'animation'; __gm.__pictures = p.assets.filter(isPicture); p.assets = p.assets.filter((a) => !isPicture(a)); p.assets.push({ id: 'lt-sheet', type: 'texture', name: 'Sheet', props: { filter: 'nearest' }, file: '', width: 32, height: 32, frames: [{ id: 'lt-f1', name: 'Frame 1', x: 0, y: 0, w: 16, h: 16 }], }); __gm.state.preview(); `); 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 // a number turning into the text "1". What matters is that it is tagged and no longer a number. check( 'a texture item is tagged, so it is no plain text beside one', JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]', JSON.stringify((await numsOf()).props.nums), ); 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); check( 'and picking a frame of a sheet writes that id under the tag', JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"lt-f1"},2]', JSON.stringify((await numsOf()).props.nums), ); check( '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 undo(); await undo(); check( 'undo puts the plain number back', JSON.stringify((await numsOf()).props.nums) === '[1,2]', JSON.stringify((await numsOf()).props.nums), ); await mutate(` const p = __gm.state.project; p.assets = [...p.assets.filter((a) => a.id !== 'lt-sheet'), ...__gm.__pictures]; delete __gm.__pictures; __gm.state.preview(); `); // 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]`, ); check( 'the list mark keeps its row on the column every row shares', lefts[0] !== null && lefts[0] === lefts[1], JSON.stringify(lefts), ); // 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(); `); 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. 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({ 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: [] }], graph: { nodes: [], edges: [] }, }); __gm.state.select({ kind: 'asset', assetId: 'celly' }); `); 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('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('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. await press_(await hook('fieldAt', 'inspector', CELLS, 0, 'x'), 'field x'); await typeText('77'); await press('Enter'); 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(await cellsOf()) === JSON.stringify([ { x: 77, y: 11 }, { x: 44, y: 55 }, ]), JSON.stringify(await cellsOf()), ); await undo(); check( 'and undo takes back just that field', JSON.stringify(await cellsOf()) === JSON.stringify([ { x: 30, y: 11 }, { x: 44, y: 55 }, ]), JSON.stringify(await cellsOf()), ); // 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'); await sleep(250); check( '+ Field grows the one item it belongs to, not every item', JSON.stringify(await cellsOf()) === JSON.stringify([ { x: 30, y: 11, field1: false }, { x: 44, y: 55 }, ]), JSON.stringify(await cellsOf()), ); 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 variable has no item shape to show', await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`), ); // A field of a struct item is renamed and retyped where it sits, both being plain value edits. await press_(await hook('nameAt', 'inspector', CELLS, 0, 'y'), 'name y'); await press('a', { ctrl: true }); await typeText('row'); await press('Enter'); check( 'renaming a field of one item leaves the other item alone', JSON.stringify(await cellsOf()) === JSON.stringify([ { x: 30, row: 11, field1: false }, { x: 44, y: 55 }, ]), JSON.stringify(await cellsOf()), ); await sleep(150); await press_(await hook('markAt', 'inspector', CELLS, 0, 'x'), 'mark x'); await pickKind('Color'); await sleep(250); check( 'and a field of a struct item can be a colour too, tagged like any item', JSON.stringify((await cellsOf())[0]) === JSON.stringify({ x: { $color: 30 }, row: 11, field1: false }), JSON.stringify((await cellsOf())[0]), ); await undo(); await undo(); await undo(); check( 'and three undos take the shape back to what it was', JSON.stringify(await cellsOf()) === JSON.stringify([ { x: 30, y: 11 }, { x: 44, y: 55 }, ]), JSON.stringify(await cellsOf()), ); // 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: {} }); __gm.showTab('graph'); __gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] }); __gm.state.preview(); `); 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 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'); __gm.state.select(null); __gm.showTab('scene'); __gm.state.preview(); `); // 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) => ["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), ); }