/** * 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, 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 * 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); }; // 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, 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'); 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'); // 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 typeText('42'); await press('Enter'); 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]); // 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 typeText('66'); await press('Enter'); const removeFirst = await json('__gm.list.removeAt(0)'); await click(removeFirst.x, removeFirst.y); await sleep(200); info = await json('__gm.list.info()'); 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 json('__gm.list.info()'); 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. 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 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)')]; check( 'and a short list after it still stacks its own', shortRows[0].y !== shortRows[1].y, JSON.stringify(shortRows), ); // 21d. `+ Item` asks for a kind, since the list does not hold one for it. await clickText('+ Item'); check( 'a list of its own offers every kind, the struct included', (await json('__gm.list.info()')).offers.join(',') === // In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there, // and `animation` (#97) is the newest of them. 'number,text,boolean,color,texture,statemachine,sound,scene,animation,struct', JSON.stringify((await json('__gm.list.info()')).offers), ); await pickKind('Text'); await sleep(200); info = await json('__gm.list.info()'); 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(); check('undo removes it again', (await json('__gm.list.info()')).items.length === 3); await shot('03-rows'); // 21e. Back on the scene tab the button carries the current count. await mutate('__gm.state.openList(null)'); 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()') }), ); 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); // 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 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 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), ); 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. mark = await json('__gm.list.markAt(1)'); await click(mark.x, mark.y); 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 json('__gm.list.info()')).kinds.join(',') === 'number,color', JSON.stringify((await json('__gm.list.info()')).kinds), ); await shot('03b-kinds'); await undo(); // 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(); `); mark = await json('__gm.list.markAt(0)'); await click(mark.x, mark.y); 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), ); const pic = await json('__gm.list.fieldAt(0)'); await click(pic.x, pic.y); 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 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), ); await shot('03c-texture'); 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(); `); 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. 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 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'); 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'); // 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. 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' }); __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)); 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('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'); 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(); 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), ); // 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`); 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 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], ); // Asked in the page: `undefined` comes back over the wire as `null`. check( 'the inspector has no item shape to show any more', 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('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()), ); const fieldMark = await json("__gm.list.markAt(0, 'x')"); await click(fieldMark.x, fieldMark.y); 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, so it is a count // button too. 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(); `); 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 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]}', 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(); `); await mutate('__gm.state.openList(null)'); 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)); }