diff --git a/README.md b/README.md index 0db0f674..d35f33dc 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,16 @@ shown on the node and editable in the inspector. appear in the inspector, can be overridden per placed instance, and each running instance gets its own mutable copy. `Get variable` / `Set variable` read and write them. A variable is a **number, a yes/no, a text or a list**; a list holds numbers, texts, yes/nos or colours (its - item kind decides how the inspector reads the one comma-separated line it is typed as) and is + item kind decides which widget an item gets) and is compared by its items, never by identity – otherwise every reload would look like an override. There is no list *of objects*: a list is stored in the project file and an object is a live handle that no reload survives. A set of objects is a list of their names, walked with `Repeat` - and `Object by name`. + and `Object by name`. A list property is shown as a button saying how many items it holds; + clicking it opens the **List** tab, which gives each item a row with the same widget the + inspector would use for that kind — plus the comma-separated line, kept for pasting a whole + list at once. The inspector stays visible beside the tab, so what the list belongs to is still + on screen. Which list the tab shows is a `ListSlot` (asset property, object override or node + setting) on the editor state, because no tab may reach into the `Editor` to switch itself on. - **Owner** = parent: a placed behavior is usually parented to the object it controls (inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up @@ -534,6 +539,7 @@ src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links) src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths) +src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) api/ PHP backend data/projects/ persisted projects tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/ ``` @@ -563,7 +569,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 419 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 436 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end @@ -619,7 +625,7 @@ to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect, -container tab, run-in-editor, pause, nested behaviors, key repeat) that build on each other's +container tab, run-in-editor, pause, nested behaviors, key repeat, list tab) that build on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c45b5d8f..b267b282 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -2,7 +2,7 @@ import type { Application } from 'pixi.js'; import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; import type { TextureStore } from '../core/store/TextureStore'; -import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui'; +import { Button, HStack, Splitter, Theme, ui, VStack, type Widget } from '../ui'; import { GameViewport } from './viewport/GameViewport'; import { SceneViewport } from './viewport/SceneViewport'; import { attachAutosave } from './autosave'; @@ -10,6 +10,7 @@ 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 { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; @@ -20,9 +21,18 @@ import type { EditorState } from './state'; import { TextureEditor } from './texture/TextureEditor'; import { installTextureImport } from './textureImport'; -export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container'; +export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container' | 'list'; -const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const; +const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container', 'list'] as const; + +const TAB_LABELS: Record = { + scene: 'Scene', + game: 'Game', + graph: 'Graph', + texture: 'Texture', + container: 'Container', + list: 'List', +}; const MIN_LEFT_WIDTH = 160; const MAX_LEFT_WIDTH = 500; @@ -42,6 +52,7 @@ export class Editor implements PlayHost { readonly graphEditor: GraphEditor; readonly textureEditor: TextureEditor; readonly containerEditor: ContainerEditor; + readonly listEditor: ListEditor; readonly hierarchy: HierarchyPanel; readonly assets: AssetPanel; readonly inspector: Inspector; @@ -57,6 +68,7 @@ export class Editor implements PlayHost { private readonly workspace: HStack; private readonly centre: VStack; private readonly tabButtons: Record; + private readonly panes: Record; private readonly toolbar: Toolbar; private fpsFrames = 0; private fpsElapsed = 0; @@ -75,6 +87,7 @@ export class Editor implements PlayHost { this.graphEditor = new GraphEditor(state); this.textureEditor = new TextureEditor(state); this.containerEditor = new ContainerEditor(state); + this.listEditor = new ListEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.play = new PlaySession(state, this); @@ -109,32 +122,30 @@ export class Editor implements PlayHost { this.centre = new VStack(); const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); tabs.minHeight = Theme.controlHeight + 8; + // Written out per tab rather than derived, because a `Record` is what makes + // adding a tab a type error everywhere it has to be handled instead of a silent omission. + this.panes = { + scene: this.viewport, + game: this.gameView, + graph: this.graphEditor, + texture: this.textureEditor, + container: this.containerEditor, + list: this.listEditor, + }; this.tabButtons = { - scene: new Button('Scene', { variant: 'ghost', width: 90, onClick: () => this.showTab('scene') }), - game: new Button('Game', { variant: 'ghost', width: 90, onClick: () => this.showTab('game') }), - graph: new Button('Graph', { variant: 'ghost', width: 90, onClick: () => this.showTab('graph') }), - texture: new Button('Texture', { - variant: 'ghost', - width: 90, - onClick: () => this.showTab('texture'), - }), - container: new Button('Container', { - variant: 'ghost', - width: 90, - onClick: () => this.showTab('container'), - }), + scene: this.tabButton('scene'), + game: this.tabButton('game'), + graph: this.tabButton('graph'), + texture: this.tabButton('texture'), + container: this.tabButton('container'), + list: this.tabButton('list'), }; - for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]); this.centre.add(tabs); - this.centre.add(this.viewport, 1); - this.centre.add(this.graphEditor, 1); - this.centre.add(this.gameView, 1); - this.centre.add(this.textureEditor, 1); - this.centre.add(this.containerEditor, 1); - this.graphEditor.visible = false; - this.gameView.visible = false; - this.textureEditor.visible = false; - this.containerEditor.visible = false; + for (const tab of TAB_ORDER) { + tabs.add(this.tabButtons[tab]); + this.centre.add(this.panes[tab], 1); + this.panes[tab].visible = tab === 'scene'; + } this.tabButtons.scene.setToggled(true); this.leftColumn.minWidth = this.leftWidth; @@ -177,6 +188,11 @@ export class Editor implements PlayHost { ) this.showTab('container'); }); + // 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.showTab('list'); + else if (this.tab === 'list') this.showTab('scene'); + }); state.on('project', () => { this.play.syncFromModel(); this.editorRun.invalidate(); @@ -187,22 +203,24 @@ export class Editor implements PlayHost { showTab(tab: CentreTab): void { this.tab = tab; - this.viewport.visible = tab === 'scene'; - this.graphEditor.visible = tab === 'graph'; - this.gameView.visible = tab === 'game'; - this.textureEditor.visible = tab === 'texture'; - this.containerEditor.visible = tab === 'container'; // The hierarchy lists what the visible tab edits; every other tab is about the scene. this.hierarchy.showObjectsOf( tab === 'container' ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId) : () => this.state.activeScene, ); - for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab); + for (const key of TAB_ORDER) { + this.panes[key].visible = key === tab; + this.tabButtons[key].setToggled(key === tab); + } this.centre.relayout(); ui().focus.validate(); } + private tabButton(tab: CentreTab): Button { + return new Button(TAB_LABELS[tab], { variant: 'ghost', width: 90, onClick: () => this.showTab(tab) }); + } + boot(): Promise { return this.projects.boot(); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d465728e..46c1d3a3 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -223,6 +223,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E objectScreen: (id: string) => containerEditor.objectScreen(id), dropPreview: () => containerEditor.dropInfo(), }, + list: { + info: () => editor.listEditor.debugInfo(), + /** Where an item's widget sits, so a check can click it – a number field has no findable text. */ + fieldAt: (index: number) => editor.listEditor.fieldAt(index), + }, world: () => editor.worldSnapshot(), playInfo: () => editor.playInfo(), /** Behaviors running without Play (`Run in editor`), and how long they have been at it. */ diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 70a0bc9d..7848d26f 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -23,7 +23,7 @@ import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from import { aspectActions, sizeCommand } from './aspectAction'; import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; -import { previewingHandlers } from './previewHandlers'; +import { listOpener, previewingHandlers } from './previewHandlers'; const VARIABLE_KIND_LABELS: Record = { number: 'Number', @@ -124,13 +124,16 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property /** Previews straight into the asset while editing; the command records the value from before the preview. */ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers { - return previewingHandlers(host, { - target: () => findAsset(host.state.project, assetId), - read: (asset) => asset.props[def.key], - write: (asset, value) => (asset.props[def.key] = value), - // No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own. - commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value), - }); + return { + ...previewingHandlers(host, { + target: () => findAsset(host.state.project, assetId), + read: (asset) => asset.props[def.key], + write: (asset, value) => (asset.props[def.key] = value), + // No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own. + commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value), + }), + ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), + }; } /** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a5e7f2cc..73c28cb4 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -39,6 +39,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, so a + * list row falls back to the comma-separated text field wherever this is not given. + */ + openList?: () => void; } export interface FieldRow { @@ -195,9 +200,16 @@ export function createField( return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) }; } case 'list': { - // Typed as one comma-separated line rather than a row per item: a board is hundreds of - // entries, and a growing list of fields would push everything else out of the inspector. - const asText = (v: PropertyValue) => listToText(isList(v) ? v : [], def.items); + const asList = (v: PropertyValue) => (isList(v) ? v : []); + const { openList } = handlers; + if (openList) { + // 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) => `${asList(v).length} items`; + const button = new Button(count(value), { variant: 'ghost', onClick: openList }); + return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) }; + } + const asText = (v: PropertyValue) => listToText(asList(v), def.items); const field = new TextField(asText(value), { onChange: (text) => handlers.onChange(listFromText(String(text), def.items)), }); diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index e436137d..070854b9 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -17,7 +17,7 @@ import { Button, Label } from '../../ui'; import { createFieldRow, type FieldHandlers } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { buildPortSection } from './portSection'; -import { previewingHandlers } from './previewHandlers'; +import { listOpener, previewingHandlers } from './previewHandlers'; /** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ export function nodeSignature(project: Project, assetId: string, nodeId: string): string { @@ -60,7 +60,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s host, effective, node.params[param.key] ?? effective.default, - paramHandlers(host, assetId, nodeId, param.key), + paramHandlers(host, assetId, nodeId, effective), ); host.fields.set(`param:${param.key}`, row); host.body.add(row.row); @@ -74,7 +74,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s host, inline, node.params[port.key] ?? inline.default, - paramHandlers(host, assetId, nodeId, port.key), + paramHandlers(host, assetId, nodeId, inline), ); host.fields.set(`input:${port.key}`, row); host.body.add(row.row); @@ -116,14 +116,23 @@ function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef { } /** Previews straight into the node's params; the command records the value from before the preview. */ -function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers { - return previewingHandlers(host, { - target: () => currentNode(host.state.project, assetId, nodeId)?.node, - read: (node) => node.params[key], - write: (node, value) => (node.params[key] = value), - remove: (node) => delete node.params[key], - commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value), - }); +function paramHandlers( + host: InspectorHost, + assetId: string, + nodeId: string, + def: PropertyDef, +): FieldHandlers { + const key = def.key; + return { + ...previewingHandlers(host, { + target: () => currentNode(host.state.project, assetId, nodeId)?.node, + read: (node) => node.params[key], + write: (node, value) => (node.params[key] = value), + remove: (node) => delete node.params[key], + commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value), + }), + ...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })), + }; } /** The node together with the graph it sits in, because its ports are derived from both. */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 24985a8f..a1564ce7 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -43,7 +43,7 @@ import { aspectActions, sizeCommand } from './aspectAction'; import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory'; import { addGap, addNote, addTitle, type InspectorHost } from './host'; import { parentRow } from './parentRow'; -import { previewingHandlers } from './previewHandlers'; +import { listOpener, previewingHandlers } from './previewHandlers'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows'; @@ -314,13 +314,16 @@ function propertyRow( host, def, values[def.key] ?? def.default, - previewingHandlers(host, { - target: () => currentRef(host.state, holderId, objectId), - read: (ref) => ref.object.overrides[def.key], - write: (ref, value) => ref.setOverride(def.key, value), - remove: (ref) => ref.setOverride(def.key, undefined), - commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value), - }), + { + ...previewingHandlers(host, { + target: () => currentRef(host.state, holderId, objectId), + read: (ref) => ref.object.overrides[def.key], + write: (ref, value) => ref.setOverride(def.key, value), + remove: (ref) => ref.setOverride(def.key, undefined), + commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value), + }), + ...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })), + }, { extra: aspectActions( host, diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 0539de18..261be5a9 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -1,7 +1,20 @@ +import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { Command } from '../../core/commands/Command'; import type { PropertyValue } from '../../core/model/Properties'; +import type { EditorState, ListSlot } from '../state'; import type { FieldHandlers } from './fieldFactory'; -import type { InspectorHost } from './host'; + +/** + * 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. + */ +export function listOpener( + state: EditorState, + def: PropertyDef, + slot: () => ListSlot, +): Pick { + return def.kind === 'list' ? { openList: () => state.openList(slot()) } : {}; +} /** Where an inspector row finds the value it edits, and what committing it means. */ export interface PreviewedValue { @@ -24,7 +37,7 @@ export interface PreviewedValue { * written back first – including "the key did not exist", via `remove`. */ export function previewingHandlers( - host: InspectorHost, + host: { state: EditorState }, spec: PreviewedValue, ): FieldHandlers { const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model); diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts new file mode 100644 index 00000000..73dbcf79 --- /dev/null +++ b/src/editor/list/ListEditor.ts @@ -0,0 +1,185 @@ +/** + * Edits one list, a row per item, with the same field widgets the inspector builds. A list row in + * the inspector has one line for a value that can be hundreds long; the inspector stays visible + * beside this tab, so what the list belongs to is still on screen while it is edited. + */ +import { + listFromText, + type ListItemKind, + listToText, + type PropertyDef, +} from '../../core/properties/PropertySchema'; +import { + Button, + HStack, + Label, + ScrollContainer, + Spacer, + TextField, + Theme, + VStack, + type Widget, +} from '../../ui'; +import { createField, type FieldContext } from '../inspector/fieldFactory'; +import type { EditorState } from '../state'; +import { type ListAccess, resolveList } from './slot'; + +const INDEX_WIDTH = 40; + +type ItemDef = Extract; + +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: Button; + private readonly bulk: TextField; + private rows = new VStack({ padding: Theme.padding, gap: Theme.gap }); + /** The structure the rows were built for; only a change here may destroy a row being typed in. */ + private signature = ''; + private setters: ((value: number | string | boolean) => void)[] = []; + private fields: Widget[] = []; + + 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); + + // The comma text is what the inspector used to show. It stays, as the way to paste a whole + // list at once – per-item rows do not replace that. + const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + this.addButton = new Button('+ Item', { variant: 'ghost', onClick: () => this.addItem() }); + footer.add(this.addButton); + this.bulk = new TextField('', { + placeholder: 'comma-separated', + onChange: (text) => this.setAll(text), + }); + footer.add(this.bulk, 1); + this.add(footer); + + state.on('list', () => this.rebuild()); + state.on('load', () => this.rebuild()); + state.on('project', () => this.refresh()); + this.rebuild(); + } + + /** For the check scripts: where an item's widget sits, since a number field carries no findable text. */ + fieldAt(index: number): { x: number; y: number } | null { + const widget = this.fields[index]; + if (!widget) return null; + const p = widget.getGlobalPosition(); + return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 }; + } + + /** For the check scripts: what the tab shows and how its rows read right now. */ + debugInfo(): { title: string; items: string[]; kind: string } { + const access = this.access(); + if (!access) return { title: '', items: [], kind: '' }; + return { + title: access.title, + items: access.value.map((item) => String(item)), + kind: access.def.items, + }; + } + + private access(): ListAccess | null { + const slot = this.state.listSlot; + return slot ? resolveList(this.state, slot) : null; + } + + /** Everything that decides how many rows there are and of what kind. */ + private signatureOf(access: ListAccess | null): string { + return access ? `${access.title}|${access.def.items}|${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] ?? '')); + this.bulk.setValue(listToText(access.value, access.def.items)); + } + + private rebuild(): 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); + this.setters = []; + this.fields = []; + + this.title.setText(access ? access.title : 'No list open'); + this.count.setText(access ? `${access.value.length} items` : ''); + this.addButton.visible = access !== null; + this.bulk.visible = access !== null; + + if (!access) { + this.rows.add(new Label('Open a list from its row in the inspector.', { style: 'muted' })); + } else { + this.bulk.setValue(listToText(access.value, access.def.items)); + 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.scroll.relayoutContent(); + this.relayout(); + } + + private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const label = new Label(String(index), { style: 'small', align: 'right' }); + label.minWidth = INDEX_WIDTH; + row.add(label); + const field = createField(this.ctx, itemDef(access.def.items, index), item, access.itemHandlers(index)); + row.add(field.widget, field.grow ? 1 : 0); + if (!field.grow) row.add(new Spacer(), 1); + row.add( + new Button('×', { + variant: 'ghost', + width: Theme.controlHeight, + onClick: () => access.replace(access.value.filter((_, i) => i !== index)), + }), + ); + this.setters.push(field.setValue); + this.fields.push(field.widget); + return row; + } + + private addItem(): void { + const access = this.access(); + if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]); + } + + private setAll(text: string): void { + const access = this.access(); + if (access) access.replace(listFromText(text, access.def.items)); + } +} + +/** One item as a property definition, so the inspector's own widget renders it. */ +function itemDef(items: ListItemKind, index: number): ItemDef { + const key = String(index); + switch (items) { + case 'number': + return { key, label: key, kind: 'number', default: 0 }; + case 'text': + return { key, label: key, kind: 'text', default: '' }; + case 'boolean': + return { key, label: key, kind: 'boolean', default: false }; + case 'color': + return { key, label: key, kind: 'color', default: 0 }; + } +} diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts new file mode 100644 index 00000000..8ac4c270 --- /dev/null +++ b/src/editor/list/slot.ts @@ -0,0 +1,155 @@ +/** + * Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the + * three cases is the spec the matching inspector section already uses, so an item edited in the + * List tab goes through the same command, the same coercion and the same undo step as the row + * it replaces. + */ +import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands'; +import { findNode, type GraphNode } from '../../core/graph/Graph'; +import { resolveDef } from '../../core/graph/nested'; +import type { Asset } from '../../core/model/Asset'; +import { behaviorLookup } from '../../core/model/behaviorPrograms'; +import type { ObjectRef } from '../../core/model/instance'; +import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties'; +import { findAsset } from '../../core/model/Project'; +import { + coerceListItem, + type PropertyDef, + type PropertySchema, + schemaForAsset, +} from '../../core/properties/PropertySchema'; +import { resolveAssetProps } from '../../core/properties/resolve'; +import type { FieldHandlers } from '../inspector/fieldFactory'; +import { currentProps, currentRef } from '../inspector/objectQueries'; +import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers'; +import type { EditorState, ListSlot } from '../state'; + +export type ListDef = Extract; + +/** What the tab needs of a slot: what is in it, and the two ways of writing it back. */ +export interface ListAccess { + readonly def: ListDef; + readonly value: ListValue; + /** What the tab header says the list belongs to. */ + readonly title: string; + /** One item, previewed and committed exactly as the inspector does it. */ + itemHandlers(index: number): FieldHandlers; + /** A whole new list in one command – adding, deleting, and the bulk text field. */ + replace(next: ListValue): void; +} + +export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null { + if (slot.kind === 'asset') return assetAccess(state, slot); + if (slot.kind === 'object') return objectAccess(state, slot); + return nodeAccess(state, slot); +} + +const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []); + +const listDefIn = (schema: PropertySchema, key: string): ListDef | null => { + const def = schema.find((d) => d.key === key); + return def?.kind === 'list' ? def : null; +}; + +const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue => + list.map((value, i) => (i === index ? item : value)); + +/** + * `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 + * put back. Everything else is the spec the matching inspector section already uses. + */ +function access( + state: EditorState, + def: ListDef, + title: string, + current: () => ListValue, + spec: Omit, 'toModel'>, +): ListAccess { + return { + def, + title, + value: current(), + itemHandlers: (index) => + previewingHandlers( + { state }, + { ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) }, + ), + // 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)), + }; +} + +function assetAccess(state: EditorState, slot: Extract): ListAccess | null { + const asset = findAsset(state.project, slot.assetId); + const def = asset && listDefIn(schemaForAsset(asset), slot.key); + if (!asset || !def) return null; + const at = () => findAsset(state.project, slot.assetId); + return access( + state, + def, + `${asset.name} · ${def.label}`, + () => { + const now = at(); + return now ? asList(resolveAssetProps(now)[slot.key]) : []; + }, + { + target: at, + read: (found) => found.props[slot.key], + write: (found, value) => (found.props[slot.key] = value), + commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value), + }, + ); +} + +function objectAccess(state: EditorState, slot: Extract): ListAccess | null { + const ref = currentRef(state, slot.holderId, slot.objectId); + const owner = ref && findAsset(state.project, ref.object.assetId); + const def = owner && listDefIn(schemaForAsset(owner), slot.key); + if (!ref || !def) return null; + return access( + state, + def, + `${ref.object.name} · ${def.label}`, + () => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]), + { + target: () => currentRef(state, slot.holderId, slot.objectId), + read: (found) => found.object.overrides[slot.key], + write: (found, value) => found.setOverride(slot.key, value), + remove: (found) => found.setOverride(slot.key, undefined), + commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value), + }, + ); +} + +function nodeAccess(state: EditorState, slot: Extract): ListAccess | null { + const found = nodeIn(state, slot); + if (!found) return null; + const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project)); + const def = nodeDef && listDefIn(nodeDef.params, slot.key); + if (!def) return null; + return access( + state, + def, + `${nodeDef.label} · ${def.label}`, + () => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default), + { + target: () => nodeIn(state, slot)?.node, + read: (node) => node.params[slot.key], + write: (node, value) => (node.params[slot.key] = value), + remove: (node) => delete node.params[slot.key], + commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value), + }, + ); +} + +/** The node together with its graph, because `resolveDef` needs both. */ +function nodeIn(state: EditorState, slot: Extract) { + const asset = findAsset(state.project, slot.assetId); + if (asset?.type !== 'behavior') return null; + const node = findNode(asset.graph, slot.nodeId); + return node ? { node, graph: asset.graph } : null; +} diff --git a/src/editor/state.ts b/src/editor/state.ts index ea525ad0..bcf56a94 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -26,6 +26,27 @@ export type Selection = | { kind: 'frame'; assetId: string; frameId: string } | 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. + */ +export type ListSlot = + | { kind: 'asset'; assetId: string; key: string } + | { kind: 'object'; holderId: string; objectId: string; key: string } + | { kind: 'node'; assetId: string; nodeId: string; key: string }; + +export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { + if (!a || !b) return a === b; + 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; + } +} + /** 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 { const picked = pickIds(objectIds, primary); @@ -53,6 +74,8 @@ export type EditorEvent = /** A different project was loaded. */ | 'load' | 'selection' + /** The List tab was pointed at another list (or at none). */ + | 'list' | 'mode' | 'save' | 'notice' @@ -75,6 +98,7 @@ export class EditorState { revision = 0; commands = new CommandStack(this.project); selection: Selection = null; + listSlot: ListSlot | null = null; mode: EditorMode = 'edit'; /** * Play is on but the world is not being ticked. A sub-state of `mode === 'play'` rather than a @@ -110,6 +134,7 @@ export class EditorState { this.revision = revision; this.commands = new CommandStack(project); this.selection = null; + this.listSlot = null; this.saveStatus = 'saved'; this.emit('load'); this.emit('project'); @@ -230,6 +255,19 @@ export class EditorState { this.emit('save'); } + /** + * 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. + */ + openList(slot: ListSlot | null): void { + if (sameSlot(this.listSlot, slot)) { + if (slot) this.emit('list'); + return; + } + this.listSlot = slot; + this.emit('list'); + } + select(selection: Selection): void { // Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition diff --git a/tests/editor/list-slot.test.ts b/tests/editor/list-slot.test.ts new file mode 100644 index 00000000..7d66177b --- /dev/null +++ b/tests/editor/list-slot.test.ts @@ -0,0 +1,137 @@ +/** + * The address the List tab edits through. 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*. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { createEmptyProject, findAsset } from '../../src/core/model/Project'; +import { variableDef } from '../../src/core/properties/PropertySchema'; +import { resolveList } from '../../src/editor/list/slot'; +import { EditorState, type ListSlot } from '../../src/editor/state'; + +function setup(): { state: EditorState; behavior: BehaviorAsset } { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('behavior', 'B'); + expect(state.execute(create)).toBe(true); + const behavior = create.asset as BehaviorAsset; + expect(state.execute(new AddVariableCommand(behavior.id, 'nums', 'list'))).toBe(true); + return { state, behavior }; +} + +describe('the address a list is edited through', () => { + let f: ReturnType; + let slot: ListSlot; + beforeEach(() => { + f = setup(); + slot = { kind: 'asset', assetId: f.behavior.id, key: 'nums' }; + resolveList(f.state, slot)!.replace([3, 1, 4]); + }); + + const list = () => resolveList(f.state, slot)!; + + it('finds the list, its item kind and what it belongs to', () => { + expect(list().value).toEqual([3, 1, 4]); + expect(list().def.items).toBe('number'); + expect(list().title).toBe('B · nums'); + }); + + it('writes one item through the property command, and undo puts it back', () => { + list().itemHandlers(1).onChange(99); + expect(list().value).toEqual([3, 99, 4]); + + f.state.commands.undo(); + expect(list().value).toEqual([3, 1, 4]); + }); + + it('takes a preview back before committing, so undo lands before the preview', () => { + const handlers = list().itemHandlers(0); + handlers.onInput(7); + handlers.onInput(8); + handlers.onChange(8); + expect(list().value).toEqual([8, 1, 4]); + + f.state.commands.undo(); + expect(list().value).toEqual([3, 1, 4]); + }); + + /** + * A row's handlers outlive a commit – the tab only rebuilds when the length changes. Reading + * the list at edit time rather than at build time is what keeps the earlier edit alive. + */ + it('lets two rows built at the same time be edited one after the other', () => { + const first = list().itemHandlers(0); + const second = list().itemHandlers(2); + first.onChange(10); + second.onChange(20); + expect(list().value).toEqual([10, 1, 20]); + }); + + it('coerces what it is given to the declared item kind', () => { + list().itemHandlers(1).onChange('42'); + expect(list().value).toEqual([3, 42, 4]); + + list().replace(['5', 'nonsense']); + expect(list().value).toEqual([5, 0]); + }); + + it('coerces a node setting the same way, where the value is not schema-checked on read', () => { + const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0); + expect(f.state.execute(add)).toBe(true); + const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' }; + resolveList(f.state, node)!.replace(['5', 'nonsense']); + expect(resolveList(f.state, node)!.value).toEqual([5, 0]); + }); + + /** A preview writes past the command, so it is the one write that has to coerce for itself. */ + it('leaves a well-typed list behind even mid-preview', () => { + list().itemHandlers(0).onInput('7'); + const previewed = (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.nums; + expect(previewed).toEqual([7, 1, 4]); + }); + + it('adds and removes whole items in one command', () => { + list().replace([...list().value, 9]); + expect(list().value).toEqual([3, 1, 4, 9]); + + list().replace(list().value.filter((_, i) => i !== 0)); + expect(list().value).toEqual([1, 4, 9]); + + f.state.commands.undo(); + expect(list().value).toEqual([3, 1, 4, 9]); + }); + + it('is nothing at all once its target is gone', () => { + expect(resolveList(f.state, { kind: 'asset', assetId: 'no-such', key: 'nums' })).toBe(null); + expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'no-such' })).toBe(null); + }); + + it('refuses a key that is not a list', () => { + expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'runInEditor' })).toBe(null); + }); + + it('keeps text items as text rather than coercing them to numbers', () => { + const behavior = findAsset(f.state.project, f.behavior.id) as BehaviorAsset; + behavior.variables.push(variableDef('words', 'list', [], 'text')); + const words: ListSlot = { kind: 'asset', assetId: f.behavior.id, key: 'words' }; + resolveList(f.state, words)!.replace(['red', 'green']); + resolveList(f.state, words)!.itemHandlers(1).onChange('blue'); + expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']); + }); + + it('reaches a node setting too, which is the other place a list lives', () => { + const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0); + expect(f.state.execute(add)).toBe(true); + const node: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'value' }; + resolveList(f.state, node)!.replace([1, 2]); + expect(resolveList(f.state, node)!.value).toEqual([1, 2]); + expect(resolveList(f.state, node)!.title).toBe('List · Value'); + + f.state.commands.undo(); + expect(resolveList(f.state, node)!.value).toEqual([]); + }); +}); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index fc4de9f7..3d0201df 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -43,7 +43,7 @@ export async function overflow(t) { } // 21a. Every tab, because each one owns a different set of controls. - for (const tab of ['scene', 'graph', 'texture', 'game', 'container']) { + for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); const spills = await json('__gm.textOverflows()'); @@ -76,7 +76,7 @@ export async function overflow(t) { // 21d. The other direction: a control narrow enough to swallow its whole label draws nothing // at all, which the overflow walk cannot see – an empty text spills over no edge. - for (const tab of ['scene', 'graph', 'texture', 'container']) { + for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); const dropped = await json('__gm.labelsDropped()'); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs new file mode 100644 index 00000000..32b5e716 --- /dev/null +++ b/tools/e2e/21-list.mjs @@ -0,0 +1,94 @@ +/** + * The List tab: a list property is a button saying how many items it has, clicking it opens the + * tab, and there a row per item edits, adds and removes them with the inspector's own widgets. + */ +export async function listTab(t) { + const { check, sleep, json, shot, click, clickText, press, typeText, mutate, undo } = t; + + /** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */ + const find = (text) => json(`__gm.locate(${JSON.stringify(text)}, false)`); + + // A throwaway behavior carrying one list variable; removed again at the end of the step. Its + // items are two digits so that no value can be confused with a row's index label. + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'listy', + type: 'behavior', + name: 'Listy', + props: { enabled: true, runInEditor: false, nums: [30, 11, 44] }, + variables: [{ key: 'nums', label: 'nums', kind: 'list', default: [], items: 'number' }], + graph: { nodes: [], edges: [] }, + }); + __gm.state.select({ kind: 'asset', assetId: 'listy' }); + `); + await sleep(150); + + // 1. The inspector shows the count, not a comma-separated line. + const button = await find('3 items'); + check('a list property reads as its item count', button !== null, JSON.stringify(button)); + check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene'); + await shot('01-count-button'); + + // 2. Clicking it opens the List tab on that very list. + await click(button.x, button.y); + await sleep(200); + check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list'); + let info = await json('__gm.list.info()'); + check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(info)); + check('with a row per item', JSON.stringify(info.items) === '["30","11","44"]', JSON.stringify(info.items)); + check('and the inspector is still beside it', (await find('VARIABLES')) !== null); + await shot('02-list-tab'); + + // 3. A row edits its item, and it is one undo step. + const second = await json('__gm.list.fieldAt(1)'); + await click(second.x, second.y); + await typeText('42'); + await press('Enter'); + await sleep(200); + info = await json('__gm.list.info()'); + check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]); + await undo(); + info = await json('__gm.list.info()'); + check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]); + + // 4. Adding and removing whole items. + await clickText('+ Item'); + await sleep(200); + info = await json('__gm.list.info()'); + check('+ Item appends one', info.items.length === 4, JSON.stringify(info.items)); + await undo(); + check('undo removes it again', (await json('__gm.list.info()')).items.length === 3); + await shot('03-rows'); + + // 5. Back on the scene tab the button carries the current count. + await mutate('__gm.state.openList(null)'); + await sleep(200); + check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene'); + await mutate(` + __gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2]; + __gm.state.preview(); + `); + await sleep(200); + check('and the count follows the value', (await find('2 items')) !== null); + + // 6. A list whose target is gone empties the tab rather than showing a stale one. + await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); + await sleep(200); + check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list'); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'listy'); + __gm.state.select(null); + __gm.state.preview(); + `); + await sleep(200); + info = await json('__gm.list.info()'); + check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title); + await shot('04-empty'); + + await mutate('__gm.state.openList(null)'); + await sleep(200); + const left = await json('__gm.state.project.assets.filter((a) => a.id === "listy").length'); + check('the step leaves the fixture as it found it', left === 0, String(left)); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index c188f6eb..1a7901d3 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -23,6 +23,7 @@ import { editorRun } from './17-editor-run.mjs'; import { pause } from './18-pause.mjs'; import { nestedBehavior } from './19-nested-behavior.mjs'; import { keyRepeat } from './20-key-repeat.mjs'; +import { listTab } from './21-list.mjs'; export const STEPS = [ hierarchy, @@ -45,4 +46,5 @@ export const STEPS = [ pause, nestedBehavior, keyRepeat, + listTab, ];