From 4857bb75e63f35e01ab15b686abec37457d48683 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 27 Sep 2026 21:56:21 +0200 Subject: [PATCH] Show lists in the inspector and variables in their own tab (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- src/core/graph/nodes/data/literals.ts | 2 +- src/editor/Editor.ts | 88 ++-- src/editor/debug/documentHooks.ts | 38 +- src/editor/inspector/Inspector.ts | 9 + src/editor/inspector/assetSection.ts | 27 +- src/editor/inspector/fieldFactory.ts | 66 ++- src/editor/inspector/nodeSection.ts | 4 +- src/editor/inspector/objectSection.ts | 40 +- src/editor/inspector/previewHandlers.ts | 26 +- src/editor/inspector/valueSpecs.ts | 2 +- src/editor/inspector/variableRows.ts | 13 +- .../list/{ListEditor.ts => InlineList.ts} | 289 ++++++----- src/editor/list/slot.ts | 38 +- src/editor/panels/assetIcons.ts | 2 +- src/editor/panels/icons/list.svg | 2 +- src/editor/state.ts | 54 +-- src/editor/variables/VariablesEditor.ts | 189 ++++++++ src/editor/variables/target.ts | 42 ++ tests/editor/list-slot.test.ts | 2 +- tests/editor/nested-list-slot.test.ts | 28 +- tests/editor/variables-tab.test.ts | 141 ++++++ tools/e2e/21-list.mjs | 451 ++++++++++++------ tools/e2e/22-struct.mjs | 34 +- tools/e2e/32-scope.mjs | 35 +- tools/e2e/lib/scaffold.mjs | 6 +- 26 files changed, 1203 insertions(+), 427 deletions(-) rename src/editor/list/{ListEditor.ts => InlineList.ts} (60%) create mode 100644 src/editor/variables/VariablesEditor.ts create mode 100644 src/editor/variables/target.ts create mode 100644 tests/editor/variables-tab.test.ts diff --git a/README.md b/README.md index 8b94b580..c919f96c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1481 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1486 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/nodes/data/literals.ts b/src/core/graph/nodes/data/literals.ts index 5c4dca4a..f0aafde6 100644 --- a/src/core/graph/nodes/data/literals.ts +++ b/src/core/graph/nodes/data/literals.ts @@ -58,5 +58,5 @@ export const AssetLiteral = literal( export const ListLiteral = literal( 'List', 'list', - 'A constant list, edited item by item in the List tab – each item with its own kind.', + 'A constant list, edited item by item in the inspector – each item with its own kind.', ); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 4d378f7c..789a0696 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -44,8 +44,8 @@ import { ContainerEditor } from './container/ContainerEditor'; import { EditorRun } from './EditorRun'; import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; -import { ListEditor } from './list/ListEditor'; -import { resolveList } from './list/slot'; +import { valuesTitle, VariablesEditor } from './variables/VariablesEditor'; +import { valuesKey } from './variables/target'; import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; @@ -66,7 +66,7 @@ import { ShaderEditor } from './shader/ShaderEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; import type { PlayFrom } from '../core/model/playSettings'; -import type { EditorState } from './state'; +import type { EditorState, ValuesTarget } from './state'; import { copySelection } from './clipboard'; import { PixelArtEditor } from './pixelart/PixelArtEditor'; import { VectorArtEditor } from './vectorart/VectorArtEditor'; @@ -92,7 +92,7 @@ export type CentreTab = | 'curve' | 'tile' | 'container' - | 'list' + | 'variables' | 'management'; /** A tab a plugin added; the prefix keeps it apart from every built-in one. */ @@ -100,10 +100,11 @@ export type PluginTabId = `plugin:${string}`; /** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */ export type TabId = CentreTab | PluginTabId; /** - * An open document (#171): an asset by its id, or the list the List tab edits – there is one. The - * user management (#179) is no document, but it opens and closes like one. + * An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its + * `valuesKey` – as many of those as were opened. The user management (#179) is no document, but it + * opens and closes like one. */ -export type DocumentId = `asset:${string}` | 'list' | 'management'; +export type DocumentId = `asset:${string}` | `values:${string}` | 'management'; /** The account behind this session; the menu for it sits at the far right of the toolbar. */ export interface EditorAccount { /** Who is signed in – the menu says so, since the toolbar has no room for a second label. */ @@ -121,7 +122,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId; type FixedTab = (typeof FIXED_TABS)[number]; /** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */ -type AssetKind = Exclude; +type AssetKind = Exclude; const FIXED_TABS = ['scene', 'game'] as const; @@ -139,7 +140,7 @@ const PANE_ORDER = [ 'curve', 'tile', 'container', - 'list', + 'variables', 'management', ] as const; @@ -158,7 +159,7 @@ const TAB_LABELS: Record = { curve: 'Curve', tile: 'Tile', container: 'Container', - list: 'List', + variables: 'Variables', management: 'User management', }; @@ -169,7 +170,7 @@ const FIXED_TAB_ICONS: Partial> = { }; function isDocument(tab: StripTab): tab is DocumentId { - return tab === 'list' || tab === 'management' || tab.startsWith('asset:'); + return tab === 'management' || tab.startsWith('asset:') || tab.startsWith('values:'); } const MIN_LEFT_WIDTH = 160; @@ -204,7 +205,9 @@ export class Editor implements PlayHost { readonly curveEditor: CurveEditor; readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; - readonly listEditor: ListEditor; + readonly variablesEditor: VariablesEditor; + /** What each open Variables tab shows, by its document. */ + private readonly valueTargets = new Map(); /** The switch per file in `data/plugins/`, in the user management tab (#179). */ readonly pluginsPane = new PluginsPane(); /** The accounts, above the plugins; null unless an administrator is signed in. */ @@ -284,7 +287,7 @@ export class Editor implements PlayHost { this.curveEditor = new CurveEditor(state); this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); - this.listEditor = new ListEditor(state); + this.variablesEditor = new VariablesEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); @@ -320,7 +323,7 @@ export class Editor implements PlayHost { // Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.tick`). this.inspector.liveVariables = (assetId) => this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId); - this.listEditor.liveVariables = this.inspector.liveVariables; + this.variablesEditor.liveVariables = this.inspector.liveVariables; this.usersPane = account.role === 'admin' @@ -395,7 +398,7 @@ export class Editor implements PlayHost { scene: this.scenePane, game: this.gamePane, ...this.assetEditors, - list: this.listEditor, + variables: this.variablesEditor, management: this.managementPane, }; // Following a call goes on in the same tab: the tab changes its document, not its place. @@ -472,10 +475,13 @@ export class Editor implements PlayHost { if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene'); else this.openDocument(`asset:${s.assetId}`); }); - // Opening a list is a request to look at it, so this switches even when the slot is unchanged. - state.on('list', () => { - if (state.listSlot) this.openDocument('list'); - else this.closeDocument('list'); + // A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252). + state.on('values', () => { + const target = state.openedValues; + if (!target) return; + const doc: DocumentId = `values:${valuesKey(target)}`; + if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target); + this.openDocument(doc); }); state.on('project', () => { this.play.syncFromModel(); @@ -486,6 +492,7 @@ export class Editor implements PlayHost { this.editorRun.invalidate(); for (const doc of this.documents.docs) this.tabs.removeTab(doc); this.documents = { docs: [], active: null }; + this.valueTargets.clear(); this.showTab('scene'); }); state.on('mode', () => this.editorRun.invalidate()); @@ -614,7 +621,7 @@ export class Editor implements PlayHost { openDocument(doc: DocumentId): void { if (!this.kindOf(doc)) return; if (this.documents.active === doc) { - // Still worth a look at the name: the List tab may have been pointed at another list. + // Still worth a look at the name: a Variables tab's owner may have been renamed. this.syncStrip(); return; } @@ -632,8 +639,7 @@ export class Editor implements PlayHost { const kind = this.kindOf(doc); if (kind && kind in this.assetEditors) this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); - // The List tab edits the one slot there is; a closed tab lets go of it. - if (doc === 'list' && this.state.listSlot) this.state.openList(null); + this.valueTargets.delete(doc); if (!wasShowing) return; if (this.documents.active) this.showDocument(this.documents.active); else this.showTab('scene'); @@ -645,8 +651,9 @@ export class Editor implements PlayHost { * a document without knowing its tab. */ showTab(tab: TabId): void { - if (tab === 'list') { - if (this.state.listSlot) this.openDocument('list'); + if (tab === 'variables') { + const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:')); + if (doc) this.openDocument(doc); return; } if (tab === 'management') { @@ -672,7 +679,10 @@ export class Editor implements PlayHost { } private kindOf(doc: DocumentId): CentreTab | null { - if (doc === 'list') return this.state.listSlot ? 'list' : null; + if (doc.startsWith('values:')) { + const target = this.valueTargets.get(doc); + return target && valuesTitle(this.state, target) !== null ? 'variables' : null; + } if (doc === 'management') return 'management'; const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; const kind = type ? TAB_OF_ASSET[type] : undefined; @@ -683,25 +693,27 @@ export class Editor implements PlayHost { const kind = this.kindOf(doc); if (!kind) return; if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length)); + const target = this.valueTargets.get(doc); + if (kind === 'variables' && target) this.variablesEditor.show(target); // Read on every showing: an account may have been changed from another tab in the meantime. if (kind === 'management') void this.usersPane?.reload(); this.display(kind, doc); } - /** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */ + /** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ private labelOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; - if (doc === 'list') { - const slot = this.state.listSlot; - return (slot && resolveList(this.state, slot)?.title) ?? ''; - } + const target = this.valueTargets.get(doc); + if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? ''; } /** The icon in front of a document's name: its asset type's, as in the asset panel. */ private iconOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG; - const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type; + const target = this.valueTargets.get(doc); + if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables; + const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG; } @@ -764,8 +776,18 @@ export class Editor implements PlayHost { * Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which * on an untoggled ghost tab sees nothing but the label (the background is not drawn at all). */ - tabRow(): { shown: TabPlace[]; hidden: StripTab[]; order: StripTab[] } { - return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] }; + tabRow(): { + shown: TabPlace[]; + hidden: StripTab[]; + order: StripTab[]; + labels: Record; + } { + return { + shown: this.tabs.places(), + hidden: this.tabs.hidden(), + order: [...this.tabs.tabs], + labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])), + }; } boot(): Promise { diff --git a/src/editor/debug/documentHooks.ts b/src/editor/debug/documentHooks.ts index 996f0a1a..e7705fa7 100644 --- a/src/editor/debug/documentHooks.ts +++ b/src/editor/debug/documentHooks.ts @@ -4,9 +4,15 @@ import { currentFramesDialog } from '../animation/FramesDialog'; import { currentRowDialog } from '../animation/RowDialog'; import { centreOf, type HookContext } from './hookHelpers'; -/** The Texture, Animation, Tile and List tabs. */ +/** Where a check looks for an inline list: the Variables tab showing, or the inspector. */ +type ListPlace = 'tab' | 'inspector'; + +/** The Texture, Animation, Tile and Variables tabs. */ export function documentHooks({ state, editor }: HookContext) { const { textureEditor } = editor; + const listsIn = (where: ListPlace) => + where === 'tab' ? editor.variablesEditor.lists() : editor.inspector.lists(); + const listIn = (where: ListPlace, title: string) => listsIn(where).find((list) => list.title === title); return { texture: { current: () => textureEditor.currentAssetId, @@ -81,14 +87,32 @@ export function documentHooks({ state, editor }: HookContext) { pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)), fitAt: () => centreOf(editor.tileEditor.fitButton), }, - list: { - info: () => editor.listEditor.debugInfo(), + values: { + /** What the Variables tab showing now is pointed at, and the variable rows it built. */ + info: () => editor.variablesEditor.debugInfo(), + /** + * Every list drawn inline – in the showing Variables tab or in the inspector – with what it + * holds and how much of it is drawn; the others below find one by its title (#252). + */ + lists: (where: ListPlace) => listsIn(where).map((list) => list.debugInfo()), /** Where an item's widget sits, so a check can click it – a number field has no findable text. */ - fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field), + fieldAt: (where: ListPlace, title: string, index: number, field = '') => + listIn(where, title)?.fieldAt(index, field) ?? null, /** The kind mark of a row, or of one field of a struct item – both are pickers. */ - markAt: (index: number, field = '') => editor.listEditor.markAt(index, field), - nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field), - removeAt: (index: number) => editor.listEditor.removeAt(index), + markAt: (where: ListPlace, title: string, index: number, field = '') => + listIn(where, title)?.markAt(index, field) ?? null, + nameAt: (where: ListPlace, title: string, index: number, field: string) => + listIn(where, title)?.nameAt(index, field) ?? null, + removeAt: (where: ListPlace, title: string, index: number) => + listIn(where, title)?.removeAt(index) ?? null, + moreAt: (where: ListPlace, title: string) => listIn(where, title)?.moreAt() ?? null, + /** The head of a list: its "+ Item" and – where it has one – its "Open". */ + headAt: (where: ListPlace, title: string) => { + const head = listIn(where, title)?.head; + return head + ? { add: centreOf(head.addButton), open: head.openButton ? centreOf(head.openButton) : null } + : null; + }, }, }; } diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 9bfd9624..a1118b82 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -16,6 +16,9 @@ import { refreshObjectSection, } from './objectSection'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; +import { INLINE_LIMIT } from '../variables/target'; +import { inlineListsIn } from '../variables/VariablesEditor'; +import type { InlineList } from '../list/InlineList'; /** * Shows the properties of the current selection. The rows are generated from the PropertySchema @@ -73,6 +76,11 @@ export class Inspector extends Panel { return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft }; } + /** For the check scripts: every list drawn inline in here, at any depth. */ + lists(): InlineList[] { + return inlineListsIn(this.host.body); + } + private emptyHost(): InspectorHost { return { state: this.state, @@ -80,6 +88,7 @@ export class Inspector extends Panel { project: () => this.state.project, liveVariables: (assetId) => this.liveVariables(assetId), followReference: (assetId, e) => this.followReference(assetId, e), + listLimit: INLINE_LIMIT, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null, diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 0da7978e..35ec8a93 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -42,12 +42,13 @@ import { Button, Dropdown, HStack, Label, Menu, Theme } from '../../ui'; import { FontPreview } from '../font/FontPreview'; import { clearSaved, hasSaved } from '../saves'; import { SoundPreview } from '../sound/SoundPreview'; +import type { VariablesOwner } from '../state'; import { aspectActions, sizeCommand } from './aspectAction'; import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory'; import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { buildShapeListSection, shapeListSignature } from './shapeSection'; import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; -import { listOpener, previewingHandlers, structFields } from './previewHandlers'; +import { listSource, previewingHandlers, structFields } from './previewHandlers'; import { assetValue } from './valueSpecs'; /** A struct field declares its own fields, starting with none. */ @@ -76,7 +77,7 @@ export function assetSignature(project: Project, assetId: string): string { } export function defSignature(def: PropertyDef | StructFieldDef): string { - // Only a struct has rows of its own, at any depth; a list is one row whatever its items are. + // Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252). const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : ''; return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; } @@ -170,7 +171,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): const spec = assetValue(host.state, assetId, def.key); return { ...previewingHandlers(host, spec), - ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), + ...listSource(host, def, () => ({ kind: 'asset', assetId, key: def.key })), ...structFields(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })), }; } @@ -193,8 +194,15 @@ function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): r return field?.kind === 'struct' ? fieldsAt(field.fields, rest) : []; } -/** Declared variables, each with its declaration editable – plus an "add" menu. */ -function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void { +/** + * Declared variables, each with its declaration editable – plus an "add" menu, and in the inspector + * the way to the same rows in a Variables tab (#252). + */ +export function buildVariables( + host: InspectorHost, + asset: BehaviorAsset | VariablesAsset, + inTab = false, +): void { const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label('VARIABLES', { style: 'title' }), 1); const add = new Dropdown('+ Variable', { variant: 'ghost' }); @@ -207,6 +215,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss new Menu(items).openBelow(add); }; header.add(add); + if (!inTab) header.add(openVariablesButton(host, { kind: 'asset', assetId: asset.id })); host.body.add(header); if (asset.variables.length === 0) addNote(host, 'No variables yet.'); @@ -244,6 +253,14 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss } } +/** Opens the variables of `owner` in a Variables tab of their own, or brings up the one they have. */ +export function openVariablesButton(host: InspectorHost, owner: VariablesOwner): Button { + return new Button('Open', { + variant: 'ghost', + onClick: () => host.state.openValues({ kind: 'variables', owner }), + }); +} + /** Not in the schema table either: a row there would share the bag with the declared values. */ const PERSISTENT: PropertyDef = { key: 'persistent', label: 'Persistent', kind: 'boolean', default: false }; diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 1e161f4d..5093dccc 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -49,6 +49,8 @@ import { type Widget, } from '../../ui'; import { ROW_ACTION_ICON_SVG } from '../panels/markIcons'; +import { InlineList, ListHead } from '../list/InlineList'; +import type { ListAccess } from '../list/slot'; /** * What a field needs beyond its own value: the project, to resolve an asset reference to a name @@ -62,6 +64,8 @@ export interface FieldContext { * plain text – only the inspector knows what a click (ping) and a double-click (open) do. */ followReference?(assetId: string, e: FederatedPointerEvent): void; + /** Items a list draws inline before "… N more" (#252); none means all of them. */ + listLimit?: number; } export interface FieldHandlers { @@ -69,8 +73,11 @@ export interface FieldHandlers { onInput: (value: PropertyValue) => void; /** Final value – commit as a command. */ onChange: (value: PropertyValue) => void; - /** Sends a list to the List tab; only the caller knows where the value it edits lives. */ - openList?: () => void; + /** + * The list a `list` row draws inline (#252), re-resolved on every call; only the caller knows + * where the value it edits lives. Without it the row only says how many items there are. + */ + list?: () => ListAccess | null; /** * Handlers for one field of a struct, `path` being the keys from the bundle down to it (a struct * field nests further, #239). Writing a field means writing the whole bundle, so only the caller – @@ -250,7 +257,7 @@ export function createFieldRow( retarget(value); return { - row, + row: withBelow(row, field, 1), setValue: (v) => { field.setValue(v); retarget(v); @@ -294,6 +301,32 @@ export interface Field { setValue(value: PropertyValue): void; /** The per-field widgets of a struct, so a caller can address one of them. */ parts?: ReadonlyMap; + /** What goes under the row rather than into it: a list's items (#252). */ + below?: Widget; +} + +/** + * The row alone, or – for a field with something below it – the row with that under it, `depth` + * indent steps in. + */ +export function withBelow(row: Widget, field: Field, depth: number): Widget { + if (!field.below) return row; + const stack = new VStack({ gap: Theme.gap }); + stack.add(row); + stack.add(indentedBy(field.below, depth)); + return stack; +} + +/** + * A widget `depth` indent steps in, as far as a struct field of that depth. A list's items go one + * step less: their index column already reads as the step in. + */ +export function indentedBy(widget: Widget, depth: number): HStack { + const steps = widget instanceof InlineList ? Math.max(0, depth - 1) : depth; + const line = new HStack({ gap: 0 }); + if (steps > 0) line.add(new Spacer(steps * (FIELD_INDENT + Theme.gap))); + line.add(widget, 1); + return line; } /** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */ @@ -452,11 +485,26 @@ export function createField( return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) }; } case 'list': { - // A board is hundreds of entries: the row says how many and hands the editing to the - // List tab, where a row per item has the space for it. - const count = (v: PropertyValue) => `${(isList(v) ? v : []).length} items`; - const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList }); - return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) }; + const count = (v: PropertyValue) => (isList(v) ? v : []).length; + const source = handlers.list; + if (!source) { + const label = new Label(`${count(value)} items`, { style: 'muted', padding: Theme.fieldPadding }); + return { widget: label, grow: true, setValue: (v) => label.setText(`${count(v)} items`) }; + } + // The items go under the row: a board is hundreds of entries, so the inspector draws the + // first few and the head opens the rest in a tab of its own. + const list = new InlineList(ctx, source, { limit: ctx.listLimit }); + const head = new ListHead(source, (kind) => list.addItem(kind)); + list.head = head; + return { + widget: head, + grow: true, + setValue: (v) => { + head.setCount(count(v)); + list.refresh(); + }, + below: list, + }; } case 'struct': { // A bundle has few fields, so unlike a list they fit under each other right here. Editing @@ -471,7 +519,7 @@ export function createField( const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own); addValue(row, sub); fields.set(field.key, sub); - stack.add(row); + stack.add(withBelow(row, sub, 1)); } return { widget: stack, diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 19906242..2e0e6b09 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -35,7 +35,7 @@ import { buildVariablePortSection, variableChoices, } from './portSection'; -import { listOpener, previewingHandlers } from './previewHandlers'; +import { listSource, previewingHandlers } from './previewHandlers'; import { buildTransitionPicker, buildTransitionSection } from './transitionSection'; import { currentNode, nodeParamValue } from './valueSpecs'; @@ -162,6 +162,6 @@ function paramHandlers( const key = def.key; return { ...previewingHandlers(host, nodeParamValue(host.state, assetId, nodeId, key)), - ...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })), + ...listSource(host, def, () => ({ kind: 'node', assetId, nodeId, key })), }; } diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 2f88d5c0..7f772ce7 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -44,10 +44,10 @@ import { deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; -import { Button, TextField, Theme } from '../../ui'; +import { Button, HStack, Label, TextField, Theme } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; -import { defSignature } from './assetSection'; +import { defSignature, openVariablesButton } from './assetSection'; import { actionIf, createFieldRow, @@ -59,7 +59,7 @@ import { import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import type { EditorState, ListSlot } from '../state'; import { parentRow } from './parentRow'; -import { listOpener, previewingHandlers, structFields } from './previewHandlers'; +import { listSource, previewingHandlers, structFields } from './previewHandlers'; import { overrideValue, tilemapSizeValue } from './valueSpecs'; import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; @@ -214,19 +214,37 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object }); } - // Asset properties (or behavior variables) with overrides; the switches are shown above. - const values = resolveSceneObjectProps(host.state.project, asset, object); - const own = objectRows(host.state.project, asset, object).filter((def) => isRowShown(def, values)); - if (own.length > 0 || asset.type === 'behavior') - addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); - if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); - for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior'); + buildOwnRows(host, holderId, objectId); markObjectRows(host, object, asset, target); if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); } +/** + * Asset properties (or behavior variables) with overrides; the switches are shown above them. A + * behavior's variables also open in a Variables tab (#252), which builds just this part. + */ +export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void { + const { project } = host.state; + const view = viewOf(project, holderId); + const object = view && findObject(view, objectId); + const asset = object && findAsset(project, object.assetId); + if (!object || !asset) return; + const values = resolveSceneObjectProps(project, asset, object); + const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values)); + const behavior = asset.type === 'behavior'; + if (behavior) { + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(new Label('VARIABLES', { style: 'title' }), 1); + if (!inTab) header.add(openVariablesButton(host, { kind: 'object', holderId, objectId })); + host.body.add(header); + } else if (own.length > 0) addTitle(host, 'PROPERTIES'); + if (own.length === 0 && behavior) addNote(host, 'The behavior declares no variables.'); + for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, behavior); + if (inTab) markObjectRows(host, object, asset, targetOf(project, holderId, objectId)); +} + /** * Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay * where they are in the scene (#136). The cells belong to the asset, so they move in every other @@ -388,7 +406,7 @@ function propertyRow( const value = values[def.key] ?? def.default; const handlers = { ...previewingHandlers(host, overrideSpec), - ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), + ...listSource(host, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), ...structFields(host, def, overrideSpec, fieldSlot), }; // A variable reads exactly like it does on its asset, which adds its own rows to the body. diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 39ae1509..c65941c6 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -9,18 +9,18 @@ import type { Command } from '../../core/commands/Command'; import type { PropertyValue } from '../../core/model/Properties'; import { type ValuePath, withValueAt } from '../../core/properties/valuePath'; import type { EditorState, ListSlot } from '../state'; +import { type LiveVariables, resolveList } from '../list/slot'; import type { FieldHandlers } from './fieldFactory'; +/** What a section knows beyond the state: a Variables asset's values in the run going on. */ +type Host = { state: EditorState; liveVariables?: LiveVariables }; + /** - * The `openList` half of a row's handlers, for the sections that know where their value lives. - * Empty for every other kind, so spreading it into the handlers is always safe. + * The `list` half of a row's handlers, for the sections that know where their value lives. Empty + * for every other kind, so spreading it into the handlers is always safe. */ -export function listOpener( - state: EditorState, - def: PropertyDef, - slot: () => ListSlot, -): Pick { - return def.kind === 'list' ? { openList: () => state.openList(slot()) } : {}; +export function listSource(host: Host, def: PropertyDef, slot: () => ListSlot): Pick { + return def.kind === 'list' ? { list: () => resolveList(host.state, slot(), host.liveVariables) } : {}; } /** @@ -29,7 +29,7 @@ export function listOpener( * field edited undo the first. */ export function structFields( - host: { state: EditorState }, + host: Host, def: PropertyDef, spec: PreviewedValue, fieldSlot?: (path: ValuePath) => ListSlot, @@ -38,11 +38,11 @@ export function structFields( } /** - * `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. + * `fieldSlot` is what a `list` field needs: its own address, so its items can be drawn and opened. * `path` leads from the bundle to the field, through struct fields as deep as they nest (#239). */ export function structFieldsOf( - host: { state: EditorState }, + host: Host, def: StructDef, spec: PreviewedValue, fieldSlot?: (path: ValuePath) => ListSlot, @@ -55,7 +55,9 @@ export function structFieldsOf( return withValueAt(bundle, path, coerceFieldValue(input, field)) ?? bundle; }, }), - ...(field.kind === 'list' && fieldSlot ? { openList: () => host.state.openList(fieldSlot(path)) } : {}), + ...(field.kind === 'list' && fieldSlot + ? { list: () => resolveList(host.state, fieldSlot(path), host.liveVariables) } + : {}), }); } diff --git a/src/editor/inspector/valueSpecs.ts b/src/editor/inspector/valueSpecs.ts index c75963c0..e370042d 100644 --- a/src/editor/inspector/valueSpecs.ts +++ b/src/editor/inspector/valueSpecs.ts @@ -14,7 +14,7 @@ import type { EditorState } from '../state'; import { currentProps, currentRef } from './objectQueries'; import type { PreviewedValue } from './previewHandlers'; -/** Where one value lives, shared by the inspector and the List tab so both write it the same way. */ +/** Where one value lives, shared by the inspector rows and the inline lists so both write it the same way. */ export function assetValue(state: EditorState, assetId: string, key: string): PreviewedValue { return { target: () => findAsset(state.project, assetId), diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index bd838514..35fcf85a 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -22,6 +22,7 @@ import { type FieldHandlers, fieldDef, type FieldRow, + indentedBy, MARK_WIDTH, nameWidth, READ_ONLY, @@ -77,16 +78,20 @@ export function buildVariable( const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); const head = gridRow(0, markLabel(def.kind), name); let setValue: (next: PropertyValue) => void = () => {}; + let below: Widget | undefined; if (def.kind === 'struct') { head.add(new Spacer(), 1); if (edit) head.add(addFieldMenu(edit, [])); } else { - setValue = addValue(head, createField(host, def, value, handlers)); + const field = createField(host, def, value, handlers); + setValue = addValue(head, field); + below = field.below; } if (keep) head.add(keepButton(keep)); if (edit) head.add(removeButton(edit.remove)); const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); + if (below) host.body.add(indentedBy(below, 1)); if (def.kind === 'struct') { if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); @@ -141,9 +146,11 @@ function structFieldRows( continue; } const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; - setters.set(field.key, addValue(row, createField(host, fieldDef(field), item, own))); + const sub = createField(host, fieldDef(field), item, own); + setters.set(field.key, addValue(row, sub)); if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); + if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2)); } return (next) => { for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field)); @@ -183,7 +190,7 @@ export function markLabel(kind: string): Widget { } /** - * A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may + * A kind mark that can be clicked – a list's way of saying what an item holds. The label may * take the button's whole width: the column is sized for its widest mark, and the usual inset * would cut `Aa` to `A…`. */ diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/InlineList.ts similarity index 60% rename from src/editor/list/ListEditor.ts rename to src/editor/list/InlineList.ts index 050f5460..d6500d6c 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/InlineList.ts @@ -1,12 +1,14 @@ /** - * Edits one list, a row per item, with the same field widgets the inspector builds -- a value that - * can be hundreds long has no room in an inspector row. The inspector stays visible beside this - * tab, so what the list belongs to is still on screen while it is edited. + * The items of a list, drawn right where the list stands (#252): under its row in the inspector, + * under a struct field, inside another list, and as the whole content of a Variables tab. A row per + * item with the same field widgets the inspector builds; a list item is drawn inline again, one + * step further in. * * A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a * row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. */ import { + Button, Dropdown, HStack, Label, @@ -42,18 +44,17 @@ import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valu import { addValue, createField, - type Field, type FieldContext, FIELD_INDENT, nameWidth, - READ_ONLY, removeButton, } from '../inspector/fieldFactory'; import { kindMenu, markPicker } from '../inspector/variableRows'; -import type { EditorState } from '../state'; -import { type ListAccess, type LiveVariables, resolveList } from './slot'; +import { shownItems } from '../variables/target'; +import type { ListAccess } from './slot'; -const INDEX_WIDTH = 40; +/** Two digits; a longer index widens its own label. */ +const INDEX_WIDTH = 16; /** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */ interface RowWidgets { @@ -74,65 +75,100 @@ const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({ }); const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => { - if (!widget) return null; + if (!widget || widget.destroyed || !widget.visible) return null; const p = widget.getGlobalPosition(); return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; }; -export class ListEditor extends VStack { - private readonly ctx: FieldContext; - private readonly title = new Label('', { style: 'bodyBold' }); - private readonly count = new Label('', { style: 'small', align: 'right' }); - private readonly scroll = new ScrollContainer(); - private readonly addButton: Dropdown; - private rows = new VStack({ padding: Theme.padding, gap: Theme.gap }); +/** + * What a list's head reads: its count, and the way to add an item. Where the list has a tab of its + * own the count is the button that opens it – an inspector row has no room for a third control. + */ +export class ListHead extends HStack { + private readonly count: Label | Button; + readonly addButton: Dropdown; + readonly openButton: Button | null; + + /** `openable` false: the head of the tab the list is open in already. */ + constructor(access: () => ListAccess | null, add: (kind: NestedKind) => void, openable = true) { + super({ gap: Theme.gap, align: 'center' }); + this.minHeight = Theme.rowHeight; + const now = access(); + this.openButton = + openable && now?.openList + ? new Button('', { variant: 'ghost', onClick: () => access()?.openList?.([]) }) + : null; + this.count = this.openButton ?? new Label('', { style: 'muted', padding: Theme.fieldPadding }); + this.add(this.count); + this.add(new Spacer(), 1); + this.addButton = new Dropdown('+ Item', { variant: 'ghost' }); + // Built on click, not once: which kinds fit depends on the list as it is then. + this.addButton.onClick = () => + new Menu(kindMenu(access()?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton); + this.add(this.addButton); + this.setCount(now?.value.length ?? 0); + } + + setCount(count: number): void { + const text = `${count} items`; + if (this.count.text === text) return; + this.count.setText(text); + // Its width follows the number, and the spacer after it has to give or take that much. + if (this.w > 0) this.relayout(); + } +} + +export interface InlineListOptions { + /** Items drawn before "… N more"; none means all of them. */ + limit?: number; +} + +export class InlineList extends VStack { /** The structure the rows were built for; only a change here may destroy a row being typed in. */ private signature = ''; private setters: ((item: PropertyValue) => void)[] = []; private rowWidgets: RowWidgets[] = []; - /** Last live refresh (play mode), in ms – the same pace as the inspector's. */ - private lastLiveRefresh = 0; - /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ - liveVariables: LiveVariables = () => null; + /** The lists drawn inside this one: each follows its own items. */ + private inner: InlineList[] = []; + private innerHeads: ListHead[] = []; + private more: Widget | null = null; + /** The head drawn for this list – count, "+ Item", "Open" – wherever its builder put it. */ + head: ListHead | null = null; - constructor(private readonly state: EditorState) { - super({ gap: 0 }); - this.ctx = { project: () => this.state.project }; - - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + 2 * Theme.padding; - header.add(this.title, 1); - header.add(this.count); - this.add(header); - - this.scroll.setContent(this.rows); - this.add(this.scroll, 1); - - const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - this.addButton = new Dropdown('+ Item', { variant: 'ghost' }); - // Built on click, not once: which kinds fit depends on the list the tab is showing. - this.addButton.onClick = () => { - const kinds = this.access()?.kinds ?? NESTED_KINDS; - new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton); - }; - footer.add(this.addButton); - footer.add(new Spacer(), 1); - this.add(footer); - - state.on('list', () => this.rebuild()); - state.on('load', () => this.rebuild()); - state.on('project', () => this.refresh()); - state.on('runtime', () => this.refreshLive()); - this.rebuild(); + constructor( + private readonly ctx: FieldContext, + readonly access: () => ListAccess | null, + private readonly options: InlineListOptions = {}, + ) { + super({ gap: Theme.gap }); + this.build(); } - /** Play mode: follow a list the behaviors fill, ~10× a second, but never under a field being typed in. */ - private refreshLive(): void { - const now = performance.now(); - if (now - this.lastLiveRefresh < 100) return; - if (ui().focus.isWithin(this)) return; - this.lastLiveRefresh = now; - this.refresh(); + /** The title of the list this draws – what the check scripts find it by. */ + get title(): string { + return this.access()?.title ?? ''; + } + + /** Adds an item of `kind` at the end – the head's `+ Item`. */ + addItem(kind: NestedKind): void { + const access = this.access(); + if (access) access.replace([...access.value, makeItem(kind, undefined)]); + } + + /** Re-reads the list: new values go into the rows, a new structure builds them again. */ + refresh(): void { + const access = this.access(); + if (this.signatureOf(access) !== this.signature) { + // A rebuild throws away the field being typed in; the next refresh after it is left does it. + if (ui().focus.isWithin(this)) return; + this.build(); + this.scrollArea()?.relayoutContent(); + return; + } + if (!access) return; + this.setters.forEach((set, i) => set(access.value[i] ?? '')); + this.inner.forEach((list) => list.refresh()); + this.innerHeads.forEach((head, i) => head.setCount(this.inner[i]?.access()?.value.length ?? 0)); } /** For the check scripts: where an item's widget sits, since a number field carries no findable text. */ @@ -157,13 +193,27 @@ export class ListEditor extends VStack { return centreOf(this.rowWidgets[index]?.names.get(field)); } - /** For the check scripts: what the tab shows and how its rows read right now. */ - debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[]; rows: number } { + /** For the check scripts: the "… N more" line, which opens the list in its tab. */ + moreAt(): { x: number; y: number } | null { + return centreOf(this.more ?? undefined); + } + + /** For the check scripts: what the list holds and how much of it the last build drew. */ + debugInfo(): { + title: string; + items: string[]; + kinds: NestedKind[]; + offers: NestedKind[]; + rows: number; + more: number; + } { const access = this.access(); - if (!access) return { title: '', items: [], kinds: [], offers: [], rows: this.rowWidgets.length }; + const base = { rows: this.rowWidgets.length, more: 0 }; + if (!access) return { ...base, title: '', items: [], kinds: [], offers: [] }; return { + ...base, // Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen. - rows: this.rowWidgets.length, + more: shownItems(access.value.length, this.options.limit).more, title: access.title, items: access.value.map((item) => textOf(item)), kinds: access.value.map(itemKindOf), @@ -171,52 +221,52 @@ export class ListEditor extends VStack { }; } - private access(): ListAccess | null { - const slot = this.state.listSlot; - return slot ? resolveList(this.state, slot, this.liveVariables) : null; - } - /** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */ private signatureOf(access: ListAccess | null): string { if (!access) return ''; - const shape = access.value.map(shapeOf).join(','); + const { shown } = shownItems(access.value.length, this.options.limit); + const shape = access.value.slice(0, shown).map(shapeOf).join(','); return `${access.title}|${shape}|${access.value.length}`; } - private refresh(): void { - const access = this.access(); - if (this.signatureOf(access) !== this.signature) { - this.rebuild(); - return; - } - if (!access) return; - this.setters.forEach((set, i) => set(access.value[i] ?? '')); + private scrollArea(): ScrollContainer | null { + for (let parent = this.parent; parent; parent = parent.parent) + if (parent instanceof ScrollContainer) return parent; + return null; } - private rebuild(): void { + private build(): void { const access = this.access(); this.signature = this.signatureOf(access); - this.rows.destroy({ children: true }); - this.rows = new VStack({ padding: Theme.padding, gap: Theme.gap }); - this.scroll.setContent(this.rows); + for (const child of [...this.children]) child.destroy({ children: true }); this.setters = []; this.rowWidgets = []; - - this.title.setText(access ? access.title : 'No list open'); - this.count.setText(access ? `${access.value.length} items` : ''); - this.addButton.visible = access !== null; - - if (!access) { - this.rows.add(new Label('Open a list from its row in the inspector.', { style: 'muted' })); - } else { - access.value.forEach((item, index) => this.rows.add(this.itemRow(access, item, index))); - if (access.value.length === 0) this.rows.add(new Label('The list is empty.', { style: 'muted' })); + this.inner = []; + this.innerHeads = []; + this.more = null; + if (access) { + const { shown, more } = shownItems(access.value.length, this.options.limit); + access.value.slice(0, shown).forEach((item, index) => this.itemRow(access, item, index)); + if (more > 0) this.add(this.moreLine(access, more)); } - this.scroll.relayoutContent(); this.relayout(); } - private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack { + /** The line under a cut-off list: how much is missing, and – where the list has a tab – the way there. */ + private moreLine(access: ListAccess, more: number): Widget { + const text = `… ${more} more`; + const open = access.openList; + this.more = open + ? new Button(`${text} – open in tab`, { variant: 'ghost', onClick: () => open([]) }) + : new Label(text, { style: 'muted' }); + const line = new HStack({ gap: Theme.gap, align: 'center' }); + line.add(new Spacer(INDEX_WIDTH)); + line.add(this.more); + line.add(new Spacer(), 1); + return line; + } + + private itemRow(access: ListAccess, item: ListValue[number], index: number): void { const kind = itemKindOf(item); // A struct item is a stack of field rows: its row grows with it and aligns to the top. const tall = kind === 'struct'; @@ -229,8 +279,15 @@ export class ListEditor extends VStack { const mark = this.kindPicker(access, [index], kind); if (tall) mark.minHeight = Theme.rowHeight; row.add(mark); + let below: Widget | null = null; if (tall) this.addStructItem(access, row, mark, index); - else { + else if (kind === 'list') { + const nested = this.nestedList(access, [index]); + row.add(nested.head, 1); + below = nested.list; + this.setters.push(() => {}); + this.rowWidgets.push(rowWidgets(mark, nested.head)); + } else { const field = this.valueField(access, [index], kind, item); addValue(row, field); this.setters.push((next) => field.setValue(untag(next) ?? '')); @@ -240,7 +297,19 @@ export class ListEditor extends VStack { row.add(remove); const widgets = this.rowWidgets[index]; if (widgets) widgets.remove = remove; - return row; + this.add(row); + if (below) this.add(indented(below, INDEX_WIDTH + Theme.gap)); + } + + /** A list inside this one: its head goes where a value would, its items below, one step further in. */ + private nestedList(access: ListAccess, path: ValuePath): { head: ListHead; list: InlineList } { + const inner = () => access.sub(path); + const list = new InlineList(this.ctx, inner, this.options); + const head = new ListHead(inner, (kind) => list.addItem(kind)); + list.head = head; + this.inner.push(list); + this.innerHeads.push(head); + return { head, list }; } /** The mark of the value at `path`, offering the other kinds. Through `untag`, so a color keeps its number when it becomes one. */ @@ -254,19 +323,14 @@ export class ListEditor extends VStack { ); } - /** The widget of a value that is not a struct: a list is its item count, opening it one level down. */ + /** The widget of a value that is neither a struct nor a list. */ private valueField( access: ListAccess, path: ValuePath, kind: NestedKind, value: PropertyValue | undefined, - ): Field { + ) { const key = String(path.at(-1)); - if (kind === 'list') - return createField(this.ctx, { key, label: key, kind: 'list', default: [] }, value ?? [], { - ...READ_ONLY, - openList: () => access.openList(path), - }); return createField( this.ctx, flatDef(key, kind as FieldKind), @@ -290,8 +354,8 @@ export class ListEditor extends VStack { /** * The fields of the struct at `path`, a line each; a field that is a struct again gets its own - * lines below, indented one step further (#239). Check scripts find a nested field by its keys - * joined with dots (`loot.count`). + * lines below, indented one step further (#239), and so does the item list of a list field. Check + * scripts find a nested field by its keys joined with dots (`loot.count`). */ private addStructFields( access: ListAccess, @@ -313,8 +377,14 @@ export class ListEditor extends VStack { }); line.add(fieldMark); line.add(name); + let below: Widget | null = null; if (field.kind === 'struct') line.add(new Spacer(), 1); - else { + else if (field.kind === 'list') { + const nested = this.nestedList(access, fieldPath); + line.add(nested.head, 1); + below = nested.list; + widgets.parts.set(id, nested.head); + } else { const value = valueAt(access.value, fieldPath); const sub = this.valueField(access, fieldPath, field.kind, value); leaves.push({ within: fieldPath.slice(1), set: addValue(line, sub) }); @@ -324,6 +394,8 @@ export class ListEditor extends VStack { widgets.names.set(id, name); line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key)))); stack.add(line); + // Items under the field's own line: their index column is the step in. + if (below) stack.add(indented(below, depth * (FIELD_INDENT + Theme.gap))); if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves); } const foot = indentedLine(depth); @@ -343,11 +415,6 @@ export class ListEditor extends VStack { const next = withValueAt(access.value, path, value); if (isList(next)) access.replace(next); } - - private addItem(kind: NestedKind): void { - const access = this.access(); - if (access) access.replace([...access.value, makeItem(kind, undefined)]); - } } /** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */ @@ -356,6 +423,14 @@ interface Leaf { set: (value: PropertyValue) => void; } +/** A widget pushed `by` pixels right, taking the rest of the width. */ +function indented(widget: Widget, by: number): HStack { + const line = new HStack({ gap: 0 }); + if (by > 0) line.add(new Spacer(by)); + line.add(widget, 1); + return line; +} + /** A line of a struct item, pushed right one step per level it sits below the item. */ function indentedLine(depth: number): HStack { const line = new HStack({ gap: Theme.gap, align: 'center' }); diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index 234a7c5f..8db35752 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -1,6 +1,7 @@ /** - * Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab - * edit gets the same command, coercion and undo step. A slot with a `path` still writes the whole value. + * Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so an + * inline list edit gets the same command, coercion and undo step. A slot with a `path` still writes + * the whole value. */ import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph'; import { withoutObjects } from '../../core/graph/liveObjects'; @@ -26,6 +27,7 @@ import { currentProps, currentRef } from '../inspector/objectQueries'; import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers'; import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs'; import type { EditorState, ListSlot } from '../state'; +import { deeperSlot } from '../variables/target'; export type ListDef = Extract; @@ -45,8 +47,13 @@ export interface ListAccess { * list from what is in it at that moment, the same rule as an item, only deeper. */ handlersAt(path: ValuePath): FieldHandlers; - /** Opens the List tab on a list inside this one, `path` leading from this list to it. */ - openList(path: ValuePath): void; + /** + * Opens a Variables tab on a list inside this one, `path` leading from this list to it – `[]` is + * this list. None for a node's list: that one is edited inline only. + */ + openList?: (path: ValuePath) => void; + /** A list inside this one, drawn inline one step further in. */ + sub(path: ValuePath): ListAccess | null; /** A whole new list in one command – adding, deleting, and the bulk text field. */ replace(next: ListValue): void; } @@ -79,10 +86,16 @@ const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? /** The owner's own list, or – with a `path` – a list nested inside its value. */ function listIn(state: EditorState, owner: Owner, slot: ListSlot): ListAccess | null { const path = slot.path ?? []; - const open = (inner: ValuePath) => state.openList({ ...slot, path: [...path, ...inner] }); + const nested: Nested = { + open: + slot.kind === 'node' + ? undefined + : (inner) => state.openValues({ kind: 'list', slot: deeperSlot(slot, inner) }), + sub: (inner) => listIn(state, owner, deeperSlot(slot, inner)), + }; if (path.length === 0) return owner.def.kind === 'list' - ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, open) + ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested) : null; // A struct is brought into its declared shape first, so a declared list not stored yet is there. const def = owner.def; @@ -112,10 +125,16 @@ function listIn(state: EditorState, owner: Owner, slot: ListSlot remove: owner.spec.remove, commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)), }, - open, + nested, ); } +/** How a list reaches the lists inside it: in a tab of their own, and inline. */ +interface Nested { + open: ((path: ValuePath) => void) | undefined; + sub: (path: ValuePath) => ListAccess | null; +} + /** * `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so * the next keystroke has to build on what is in there now, and the commit on what the restore @@ -127,7 +146,7 @@ function access( title: string, current: () => ListValue, spec: Omit, 'toModel'>, - open: (path: ValuePath) => void, + nested: Nested, ): ListAccess { // The value keeps the kind it has: a color item written from its field stays a color. const handlersAt = (path: ValuePath): FieldHandlers => @@ -150,7 +169,8 @@ function access( }, itemHandlers: (index) => handlersAt([index]), handlersAt, - openList: open, + openList: nested.open, + sub: nested.sub, // No coercion here: every command behind `commit` runs the value through `coerceValue`. // A preview does not, which is why `toModel` above has to. replace: (next) => state.execute(spec.commit(next)), diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index cdd33f5c..91392974 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -56,7 +56,7 @@ export const ASSET_ICON_SVG: Record = { vectorart, }; -/** The List tab's: it edits a list value, which is no asset. */ +/** A Variables tab showing one list (#252): a list value is no asset. */ export const LIST_ICON_SVG = list; /** A group's row in the hierarchy (#241): a folder, as it only sorts what is listed. */ diff --git a/src/editor/panels/icons/list.svg b/src/editor/panels/icons/list.svg index d8c1914f..11f0ba4d 100644 --- a/src/editor/panels/icons/list.svg +++ b/src/editor/panels/icons/list.svg @@ -1,5 +1,5 @@ - + diff --git a/src/editor/state.ts b/src/editor/state.ts index 24d2abfa..cc589aa3 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -57,11 +57,10 @@ export type Selection = | null; /** - * Which list the List tab edits. Deliberately not a `Selection`: a list is not something one - * selects, it is a value inside something already selected, and exactly three places hold one. - * `path` leads to a list nested inside the value `key` names (#239): a key per struct field, an - * index per list item – `[3, 'drops']` is the list in field `drops` of item 3. A struct item needs - * no step of its own to be edited, because the List tab renders it inline. + * Where a list lives. Deliberately not a `Selection`: a list is not something one selects, it is a + * value inside something already selected, and exactly three places hold one. `path` leads to a + * list nested inside the value `key` names (#239): a key per struct field, an index per list item – + * `[3, 'drops']` is the list in field `drops` of item 3. */ export type ListSlot = { path?: ValuePath } & ( | { kind: 'asset'; assetId: string; key: string } @@ -69,20 +68,12 @@ export type ListSlot = { path?: ValuePath } & ( | { kind: 'node'; assetId: string; nodeId: string; key: string } ); -function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { - if (!a || !b) return a === b; - const pa = a.path ?? []; - const pb = b.path ?? []; - if (pa.length !== pb.length || pa.some((step, i) => step !== pb[i])) return false; - switch (a.kind) { - case 'asset': - return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key; - case 'object': - return b.kind === 'object' && b.holderId === a.holderId && b.objectId === a.objectId && b.key === a.key; - case 'node': - return b.kind === 'node' && b.assetId === a.assetId && b.nodeId === a.nodeId && b.key === a.key; - } -} +/** Whose variables a Variables tab shows (#252): a behavior or Variables asset, or a placed behavior. */ +export type VariablesOwner = + { kind: 'asset'; assetId: string } | { kind: 'object'; holderId: string; objectId: string }; + +/** What a Variables tab shows: every variable of an owner, or one list at any depth. */ +export type ValuesTarget = { kind: 'variables'; owner: VariablesOwner } | { kind: 'list'; slot: ListSlot }; /** Builds an object selection; the last id is the primary one unless given explicitly. */ export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection { @@ -188,8 +179,8 @@ export type EditorEvent = /** A different project was loaded. */ | 'load' | 'selection' - /** The List tab was pointed at another list (or at none). */ - | 'list' + /** A Variables tab was asked for (`openedValues`). */ + | 'values' /** An asset was opened (double-click, Enter, or just created): its tab should show. */ | 'open' /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ @@ -214,7 +205,8 @@ export class EditorState { revision = 0; commands = new CommandStack(this.project); selection: Selection = null; - listSlot: ListSlot | null = null; + /** The last target asked for by `openValues`; the tabs themselves are the editor's. */ + openedValues: ValuesTarget | null = null; mode: EditorMode = 'edit'; /** * A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all @@ -265,7 +257,7 @@ export class EditorState { this.revision = revision; this.commands = new CommandStack(project); this.selection = null; - this.listSlot = null; + this.openedValues = null; this.clipboard = null; this.saveStatus = 'saved'; this.emit('load'); @@ -426,22 +418,18 @@ export class EditorState { } /** - * Points the List tab at a list. No tab knows the `Editor`, so this is how the button on a - * list row gets there: the state changes, the editor switches. + * Asks for a Variables tab on `target` – a new one, or the one already showing it. No tab knows + * the `Editor`, so this is how the button on a list or a VARIABLES header gets there. */ - openList(slot: ListSlot | null): void { - if (sameSlot(this.listSlot, slot)) { - if (slot) this.emit('list'); - return; - } - this.listSlot = slot; - this.emit('list'); + openValues(target: ValuesTarget): void { + this.openedValues = target; + this.emit('values'); } /** * Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the * Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets - * there, and like `openList` it works without knowing the `Editor`. + * there, and like `openValues` it works without knowing the `Editor`. */ openAsset(assetId: string): void { this.select({ kind: 'asset', assetId }); diff --git a/src/editor/variables/VariablesEditor.ts b/src/editor/variables/VariablesEditor.ts new file mode 100644 index 00000000..b481a456 --- /dev/null +++ b/src/editor/variables/VariablesEditor.ts @@ -0,0 +1,189 @@ +/** + * The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior, + * with the very rows the inspector draws for them – or one list, as deep inside them as it sits. + * One editor for every such tab, pointed at the target of the one showing, like the asset editors. + */ +import type { Container } from 'pixi.js'; +import { findObject } from '../../core/model/hierarchy'; +import { findAsset } from '../../core/model/Project'; +import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; +import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; +import type { InspectorHost } from '../inspector/host'; +import { viewOf } from '../inspector/objectQueries'; +import { buildOwnRows, objectSignature, refreshObjectSection } from '../inspector/objectSection'; +import { InlineList, ListHead } from '../list/InlineList'; +import { type LiveVariables, resolveList } from '../list/slot'; +import type { EditorState, ValuesTarget } from '../state'; + +/** The asset or placed object whose variables `target` shows, when it still has any to show. */ +function ownerOf(state: EditorState, target: Extract) { + const { owner } = target; + if (owner.kind === 'asset') { + const asset = findAsset(state.project, owner.assetId); + return asset?.type === 'behavior' || asset?.type === 'variables' ? { asset, name: asset.name } : null; + } + const view = viewOf(state.project, owner.holderId); + const object = view && findObject(view, owner.objectId); + const asset = object && findAsset(state.project, object.assetId); + return object && asset?.type === 'behavior' ? { asset, name: object.name || asset.name } : null; +} + +/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */ +export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null { + if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null; + const owner = ownerOf(state, target); + return owner ? `${owner.name} · Variables` : null; +} + +export class VariablesEditor extends VStack { + private readonly title = new Label('', { style: 'bodyBold' }); + private readonly scroll = new ScrollContainer(); + private host: InspectorHost; + private target: ValuesTarget | null = null; + private signature = ''; + private lastLiveRefresh = 0; + private rebuildWhenLeft = false; + private readonly releaseFocusHandler: () => void; + /** The list a list target draws, and its head. */ + private list: { head: ListHead; items: InlineList } | null = null; + /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ + liveVariables: LiveVariables = () => null; + + constructor(private readonly state: EditorState) { + super({ gap: 0 }); + const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + header.minHeight = Theme.controlHeight + 2 * Theme.padding; + header.add(this.title, 1); + this.add(header); + this.host = this.emptyHost(); + this.scroll.setContent(this.host.body); + this.add(this.scroll, 1); + state.on('project', () => this.refresh()); + state.on('runtime', () => this.refreshLive()); + this.releaseFocusHandler = ui().focus.onFocusChange(() => { + if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild(); + }); + } + + /** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */ + override destroy(options?: Parameters[0]): void { + this.releaseFocusHandler(); + super.destroy(options); + } + + /** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */ + show(target: ValuesTarget): void { + this.target = target; + this.rebuild(); + } + + /** For the check scripts: what the tab shows, and the lists drawn in it. */ + debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } { + return { + title: this.title.text, + target: this.target, + rows: [...this.host.fields.keys()], + }; + } + + /** Every list drawn in the tab, at any depth. */ + lists(): InlineList[] { + return inlineListsIn(this.host.body); + } + + private emptyHost(): InspectorHost { + return { + state: this.state, + project: () => this.state.project, + liveVariables: (assetId) => this.liveVariables(assetId), + body: new VStack({ padding: Theme.padding, gap: Theme.gap }), + fields: new Map(), + nameField: null, + }; + } + + private currentSignature(): string { + const target = this.target; + if (!target) return ''; + if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing'; + const { owner } = target; + if (!ownerOf(this.state, target)) return 'missing'; + return owner.kind === 'asset' + ? assetSignature(this.state.project, owner.assetId) + : objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId); + } + + private rebuild(): void { + this.rebuildWhenLeft = false; + this.host.body.destroy({ children: true }); + this.host = this.emptyHost(); + this.list = null; + this.signature = this.currentSignature(); + const target = this.target; + this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? ''); + if (target?.kind === 'list') this.buildList(target.slot); + else if (target) { + const owner = ownerOf(this.state, target); + if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true); + else if (target.owner.kind === 'object') + buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true); + } + this.scroll.setContent(this.host.body); + } + + private buildList(slot: Extract['slot']): void { + const access = () => resolveList(this.state, slot, this.liveVariables); + const items = new InlineList(this.host, access); + const head = new ListHead(access, (kind) => items.addItem(kind), false); + items.head = head; + this.host.body.add(head); + this.host.body.add(items); + this.list = { head, items }; + } + + /** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */ + private refreshLive(): void { + if (!this.visible) return; + const now = performance.now(); + if (now - this.lastLiveRefresh < 100) return; + if (ui().focus.isWithin(this)) return; + this.lastLiveRefresh = now; + this.refresh(); + } + + private refresh(): void { + const target = this.target; + if (!target) return; + this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? ''); + if (this.currentSignature() !== this.signature) { + if (ui().focus.isWithin(this)) { + this.rebuildWhenLeft = true; + return; + } + this.rebuild(); + return; + } + if (target.kind === 'list') { + this.list?.head.setCount(this.list.items.access()?.value.length ?? 0); + this.list?.items.refresh(); + return; + } + const { owner } = target; + const ok = + owner.kind === 'asset' + ? refreshAssetSection(this.host, owner.assetId) + : refreshObjectSection(this.host, owner.holderId, owner.objectId); + if (!ok) this.rebuild(); + } +} + +/** Every inline list below `root`, outermost first – what the check scripts pick one from. */ +export function inlineListsIn(root: Container): InlineList[] { + const found: InlineList[] = []; + const walk = (node: Container): void => { + if (node instanceof InlineList) found.push(node); + for (const child of node.children) walk(child); + }; + walk(root); + return found; +} diff --git a/src/editor/variables/target.ts b/src/editor/variables/target.ts new file mode 100644 index 00000000..fd183424 --- /dev/null +++ b/src/editor/variables/target.ts @@ -0,0 +1,42 @@ +/** + * The rules of the Variables tab (#252) that need no widget: which document a target is, and how + * much of a list is drawn inline. + */ +import type { ListSlot, ValuesTarget, VariablesOwner } from '../state'; + +/** + * A key that is the same for the same target however it was built – the document id of its tab, + * so asking twice for one list brings up its tab instead of opening a second. + */ +export function valuesKey(target: ValuesTarget): string { + if (target.kind === 'variables') return `vars|${ownerKey(target.owner)}`; + return `list|${slotKey(target.slot)}`; +} + +function ownerKey(owner: VariablesOwner): string { + return owner.kind === 'asset' ? `asset|${owner.assetId}` : `object|${owner.holderId}|${owner.objectId}`; +} + +function slotKey(slot: ListSlot): string { + const where = + slot.kind === 'asset' + ? `asset|${slot.assetId}` + : slot.kind === 'object' + ? `object|${slot.holderId}|${slot.objectId}` + : `node|${slot.assetId}|${slot.nodeId}`; + return `${where}|${JSON.stringify([slot.key, ...(slot.path ?? [])])}`; +} + +/** The list one step further down: `inner` leads from the slot's list to the one inside it. */ +export function deeperSlot(slot: ListSlot, inner: ListSlot['path']): ListSlot { + return { ...slot, path: [...(slot.path ?? []), ...(inner ?? [])] }; +} + +/** How many items the inspector draws of a list before it says "… N more". */ +export const INLINE_LIMIT = 20; + +/** The items drawn and the ones left out: all of them without a limit. */ +export function shownItems(count: number, limit = Infinity): { shown: number; more: number } { + const shown = Math.min(count, Math.max(0, limit)); + return { shown, more: count - shown }; +} diff --git a/tests/editor/list-slot.test.ts b/tests/editor/list-slot.test.ts index 1689f190..d34195d0 100644 --- a/tests/editor/list-slot.test.ts +++ b/tests/editor/list-slot.test.ts @@ -1,5 +1,5 @@ /** - * The address the List tab edits through. What matters here is not that a list can be read, but + * The address an inline list edits through (#252). What matters here is not that a list can be read, but * that writing one item goes through the same command (and the same undo) as the inspector row * it replaces, and that a row keeps answering for the list as it is *now*. */ diff --git a/tests/editor/nested-list-slot.test.ts b/tests/editor/nested-list-slot.test.ts index bfdd89a6..39e38e23 100644 --- a/tests/editor/nested-list-slot.test.ts +++ b/tests/editor/nested-list-slot.test.ts @@ -1,5 +1,5 @@ /** - * Editing a value three levels deep (#239): the List tab reaches a nested list by a path of keys + * 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. */ @@ -77,15 +77,22 @@ describe('a list nested in the items of a list', () => { expect(props(f).rows).toEqual(rows); }); - it('opens the tab one level further down', () => { - at([0, 'drops'])!.openList([1, 'x']); - expect(f.state.listSlot).toEqual({ - kind: 'asset', - assetId: f.id, - key: 'rows', - path: [0, 'drops', 1, 'x'], + 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', () => { @@ -125,9 +132,8 @@ describe('a field of a struct declared inside a struct', () => { expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } }); }); - it('opens a list field in the List tab by its path', () => { - handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!(); - const list = resolveList(f.state, f.state.listSlot!)!; + 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']] } }); diff --git a/tests/editor/variables-tab.test.ts b/tests/editor/variables-tab.test.ts new file mode 100644 index 00000000..3c871df1 --- /dev/null +++ b/tests/editor/variables-tab.test.ts @@ -0,0 +1,141 @@ +/** + * The rules behind the Variables tab (#252): which document a target is – the same list asked for + * twice is one tab, a list inside it another – what the tab says, and how much of a list the + * inspector draws. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { PlaceAssetCommand, RenameSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { resolveList } from '../../src/editor/list/slot'; +import { EditorState, type ListSlot, type ValuesTarget } from '../../src/editor/state'; +import { deeperSlot, INLINE_LIMIT, shownItems, valuesKey } from '../../src/editor/variables/target'; +import { valuesTitle } from '../../src/editor/variables/VariablesEditor'; + +function setup() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('behavior', 'Pickup'); + expect(state.execute(create)).toBe(true); + const id = create.asset.id; + expect(state.execute(new AddVariableCommand(id, 'loots', 'list'))).toBe(true); + return { state, id }; +} + +describe('the document of a Variables tab', () => { + const slot: ListSlot = { kind: 'asset', assetId: 'a', key: 'loots' }; + + it('is the same for the same list, however the address was built', () => { + const built = { key: 'loots', assetId: 'a', kind: 'asset', path: [] } as const; + expect(valuesKey({ kind: 'list', slot })).toBe(valuesKey({ kind: 'list', slot: built })); + expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) })).toBe( + valuesKey({ kind: 'list', slot: { ...slot, path: [2] } }), + ); + }); + + it('is another one for a list inside it, so opening that one adds a tab', () => { + const outer = valuesKey({ kind: 'list', slot }); + const inner = valuesKey({ kind: 'list', slot: deeperSlot(slot, [2, 'drops']) }); + expect(inner).not.toBe(outer); + // A key and an index that read alike are still two steps. + expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, ['2']) })).not.toBe( + valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) }), + ); + }); + + it('keeps the variables of an owner apart from a list of it and from another owner', () => { + const asset: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: 'a' } }; + const object: ValuesTarget = { + kind: 'variables', + owner: { kind: 'object', holderId: 's', objectId: 'a' }, + }; + const keys = new Set([asset, object, { kind: 'list', slot } as const].map(valuesKey)); + expect(keys.size).toBe(3); + }); +}); + +describe('how much of a list the inspector draws', () => { + it('draws everything up to the limit and counts the rest', () => { + expect(shownItems(5, INLINE_LIMIT)).toEqual({ shown: 5, more: 0 }); + expect(shownItems(INLINE_LIMIT, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 0 }); + expect(shownItems(23, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 3 }); + }); + + it('draws all of it without a limit – the tab', () => { + expect(shownItems(500)).toEqual({ shown: 500, more: 0 }); + }); +}); + +describe('what a Variables tab says', () => { + let f: ReturnType; + beforeEach(() => (f = setup())); + + it('names the asset, the placed object and the list', () => { + expect(valuesTitle(f.state, { kind: 'variables', owner: { kind: 'asset', assetId: f.id } })).toBe( + 'Pickup · Variables', + ); + const createScene = new CreateAssetCommand('scene', 'Level'); + expect(f.state.execute(createScene)).toBe(true); + const scene = createScene.asset.id; + const place = new PlaceAssetCommand(scene, f.id, { x: 0, y: 0 }); + expect(f.state.execute(place)).toBe(true); + const object: ValuesTarget = { + kind: 'variables', + owner: { kind: 'object', holderId: scene, objectId: place.objectId }, + }; + expect(valuesTitle(f.state, object)).toBe('Pickup · Variables'); + expect(f.state.execute(new RenameSceneObjectCommand(scene, place.objectId, 'Coin 3'))).toBe(true); + expect(valuesTitle(f.state, object)).toBe('Coin 3 · Variables'); + const slot: ListSlot = { kind: 'asset', assetId: f.id, key: 'loots' }; + expect(valuesTitle(f.state, { kind: 'list', slot })).toBe('Pickup · loots'); + }); + + it('says nothing once the owner is gone – the tab closes then', () => { + const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: f.id } }; + const list: ValuesTarget = { kind: 'list', slot: { kind: 'asset', assetId: f.id, key: 'loots' } }; + expect(f.state.execute(new DeleteAssetCommand(f.id))).toBe(true); + expect(valuesTitle(f.state, target)).toBe(null); + expect(valuesTitle(f.state, list)).toBe(null); + }); + + it('has no Variables tab for an asset that declares none', () => { + const texture = new CreateAssetCommand('texture', 'T'); + expect(f.state.execute(texture)).toBe(true); + const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: texture.asset.id } }; + expect(valuesTitle(f.state, target)).toBe(null); + }); +}); + +describe('opening a list in a tab', () => { + let f: ReturnType; + beforeEach(() => (f = setup())); + + it('asks for a tab on the list itself, or on one inside it', () => { + const list = resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'loots' })!; + list.replace([[1, 2]]); + let asked = 0; + f.state.on('values', () => (asked += 1)); + list.openList!([]); + expect(f.state.openedValues).toEqual({ + kind: 'list', + slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [] }, + }); + list.openList!([0]); + expect(f.state.openedValues).toEqual({ + kind: 'list', + slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [0] }, + }); + expect(asked).toBe(2); + }); + + it('offers no tab for a list a node holds inline', () => { + const add = new AddNodeCommand(f.id, 'List', 0, 0); + expect(f.state.execute(add)).toBe(true); + const node = resolveList(f.state, { kind: 'node', assetId: f.id, nodeId: add.node.id, key: 'value' })!; + expect(node.openList).toBeUndefined(); + node.replace([[1]]); + expect(node.sub([0])!.openList).toBeUndefined(); + }); +}); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index baf7d9e4..86b3ffff 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -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)); } diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 03851c3e..5150c548 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -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 = () => diff --git a/tools/e2e/32-scope.mjs b/tools/e2e/32-scope.mjs index 74ac70bb..3f36e07a 100644 --- a/tools/e2e/32-scope.mjs +++ b/tools/e2e/32-scope.mjs @@ -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. diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 0629d0a3..3aeedd5e 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -170,12 +170,12 @@ const DOCUMENT_KINDS = [ 'curve', 'tile', 'container', - 'list', + 'variables', 'management', ]; /** - * Clicks the tab of an open document (`asset:` or `list`), through the `»` menu when the row + * Clicks the tab of an open document (`asset:` or `values:`), 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; })()`);