Give a list its own tab instead of a comma line
A list property in the inspector was a single text field holding a comma
separated line -- unreadable past a handful of items, and it could not
carry anything but text. It becomes a button ("3 items") that opens a
sixth centre tab, where every item is a row built by the very widget the
inspector would build for that item kind.
The renderer was free: a PropertyDef of kind 'list' already carries
`items`, so item i becomes a synthetic def of that kind -- number, text,
boolean and colour rows appear without a new branch.
The way into the tab goes through the state (EditorState.listSlot,
openList(), event 'list'), like every other tab switch here; a tab may
not call showTab itself. ListSlot lives next to Selection rather than in
the tab folder, or state.ts would have to import upwards.
A row's handlers must not close over a snapshot of the list: they outlive
a commit, since the tab only rebuilds when the length changes, and the
second edited row would otherwise revive the value the first replaced.
showTab switched five `visible` lines by hand, where a sixth tab is
silently forgotten; it is a Record<CentreTab, Widget> now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a440ba433
commit
e52e95c0e9
15 files changed
+750
-70
No files matched your search
@@ -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.
|
||||
|
||||
+50
-32
@@ -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<CentreTab, string> = {
|
||||
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<CentreTab, Button>;
|
||||
private readonly panes: Record<CentreTab, Widget>;
|
||||
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<CentreTab, …>` 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<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<VariableKind, string> = {
|
||||
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<Asset>(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<Asset>(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. */
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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<GraphNode>(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<GraphNode>(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. */
|
||||
|
||||
@@ -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<ObjectRef>(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<ObjectRef>(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,
|
||||
|
||||
@@ -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<FieldHandlers, 'openList'> {
|
||||
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<Target, Model = PropertyValue> {
|
||||
@@ -24,7 +37,7 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
* written back first – including "the key did not exist", via `remove`.
|
||||
*/
|
||||
export function previewingHandlers<Target, Model = PropertyValue>(
|
||||
host: InspectorHost,
|
||||
host: { state: EditorState },
|
||||
spec: PreviewedValue<Target, Model>,
|
||||
): FieldHandlers {
|
||||
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
|
||||
|
||||
@@ -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<PropertyDef, { kind: ListItemKind }>;
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<PropertyDef, { kind: 'list' }>;
|
||||
|
||||
/** 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<Target>(
|
||||
state: EditorState,
|
||||
def: ListDef,
|
||||
title: string,
|
||||
current: () => ListValue,
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||||
): ListAccess {
|
||||
return {
|
||||
def,
|
||||
title,
|
||||
value: current(),
|
||||
itemHandlers: (index) =>
|
||||
previewingHandlers<Target>(
|
||||
{ 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<ListSlot, { kind: 'asset' }>): 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<Asset>(
|
||||
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<ListSlot, { kind: 'object' }>): 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<ObjectRef>(
|
||||
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<ListSlot, { kind: 'node' }>): 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<GraphNode>(
|
||||
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<ListSlot, { kind: 'node' }>) {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof setup>;
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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()');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user