Show lists in the inspector and variables in their own tab (#252)
game-maker / gate (push) Successful in 3m24s
game-maker / suite (push) Successful in 10m22s

The List tab could show one list at a time and was the only place to
see its items. A list now shows its items right where it lives, and a
new Variables tab shows all variables of an asset or placed object.

- InlineList draws the items of a list under its row in the inspector,
  in struct fields and in the Variables tab. A list inside a list or a
  struct field is drawn the same way, one step indented.
- The index column is the indent. Items sit under their list without an
  own margin, and the index column is 16 px wide instead of 28 px.
- Above 20 items (INLINE_LIMIT) the rest collapses into "... N more -
  open in tab". During a run the rows show the values from the game.
- ListHead puts "N items" and "+ Item" in one row. "N items" opens the
  list in a tab. A separate Open button squeezed the counter to zero
  width in the narrow inspector column.
- VariablesEditor shows every variable of a behavior asset, a variables
  asset or a placed behavior. On an asset it edits the declarations. On
  a placed object an edit only overrides that object, with the usual
  reset and push buttons.
- Opening a list inside a tab adds a new tab and keeps the open one.
  valuesKey gives each list its own document, so a list that is already
  open is shown instead of opened twice.
- Titles read "Pickup · Variables" or "Listy · nums · [3]". The tab
  closes when its asset or object is deleted.
- Lists on node inputs stay inline only, a node owns no variables.
- The List tab, ListEditor and the __gm.list hook are gone.

Tests:
- ./check.sh: 181 files, 2645 tests, CHECK EXIT 0
- GM_STEPS=1,21,22,32: ALL PASSED, 123 checks
- e2e 21 is rewritten for inline lists and the new tab, 22 and 32 are
  adapted. Sum in README: 1486 (+6 in 21, -1 in 22), counted by hand.
- Counter-probes fail as expected: sub-lists not told apart (1 unit
  test), sub-list replacing the open tab, all items drawn inline (60
  rows instead of 20), no live values (0 rows at 24 items in a run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 21:56:21 +02:00
1 parent 92fad6b9e3
commit 4857bb75e6
26 files changed
+1203 -427

No files matched your search

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

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 650 B

+21 -33
View File
@@ -57,11 +57,10 @@ export type Selection =
| null; | null;
/** /**
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one * Where a list lives. Deliberately not a `Selection`: a list is not something one selects, it is a
* selects, it is a value inside something already selected, and exactly three places hold one. * value inside something already selected, and exactly three places hold one. `path` leads to a
* `path` leads to a list nested inside the value `key` names (#239): a key per struct field, an * list nested inside the value `key` names (#239): a key per struct field, an index per list item –
* index per list item – `[3, 'drops']` is the list in field `drops` of item 3. A struct item needs * `[3, 'drops']` is the list in field `drops` of item 3.
* no step of its own to be edited, because the List tab renders it inline.
*/ */
export type ListSlot = { path?: ValuePath } & ( export type ListSlot = { path?: ValuePath } & (
| { kind: 'asset'; assetId: string; key: string } | { kind: 'asset'; assetId: string; key: string }
@@ -69,20 +68,12 @@ export type ListSlot = { path?: ValuePath } & (
| { kind: 'node'; assetId: string; nodeId: string; key: string } | { kind: 'node'; assetId: string; nodeId: string; key: string }
); );
function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { /** Whose variables a Variables tab shows (#252): a behavior or Variables asset, or a placed behavior. */
if (!a || !b) return a === b; export type VariablesOwner =
const pa = a.path ?? []; { kind: 'asset'; assetId: string } | { kind: 'object'; holderId: string; objectId: string };
const pb = b.path ?? [];
if (pa.length !== pb.length || pa.some((step, i) => step !== pb[i])) return false; /** What a Variables tab shows: every variable of an owner, or one list at any depth. */
switch (a.kind) { export type ValuesTarget = { kind: 'variables'; owner: VariablesOwner } | { kind: 'list'; slot: ListSlot };
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. */ /** 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 { export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
@@ -188,8 +179,8 @@ export type EditorEvent =
/** A different project was loaded. */ /** A different project was loaded. */
| 'load' | 'load'
| 'selection' | 'selection'
/** The List tab was pointed at another list (or at none). */ /** A Variables tab was asked for (`openedValues`). */
| 'list' | 'values'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */ /** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open' | 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
@@ -214,7 +205,8 @@ export class EditorState {
revision = 0; revision = 0;
commands = new CommandStack(this.project); commands = new CommandStack(this.project);
selection: Selection = null; selection: Selection = null;
listSlot: ListSlot | null = null; /** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null;
mode: EditorMode = 'edit'; mode: EditorMode = 'edit';
/** /**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all * A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
@@ -265,7 +257,7 @@ export class EditorState {
this.revision = revision; this.revision = revision;
this.commands = new CommandStack(project); this.commands = new CommandStack(project);
this.selection = null; this.selection = null;
this.listSlot = null; this.openedValues = null;
this.clipboard = null; this.clipboard = null;
this.saveStatus = 'saved'; this.saveStatus = 'saved';
this.emit('load'); this.emit('load');
@@ -426,22 +418,18 @@ export class EditorState {
} }
/** /**
* Points the List tab at a list. No tab knows the `Editor`, so this is how the button on a * Asks for a Variables tab on `target` – a new one, or the one already showing it. No tab knows
* list row gets there: the state changes, the editor switches. * the `Editor`, so this is how the button on a list or a VARIABLES header gets there.
*/ */
openList(slot: ListSlot | null): void { openValues(target: ValuesTarget): void {
if (sameSlot(this.listSlot, slot)) { this.openedValues = target;
if (slot) this.emit('list'); this.emit('values');
return;
}
this.listSlot = slot;
this.emit('list');
} }
/** /**
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the * Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets * Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
* there, and like `openList` it works without knowing the `Editor`. * there, and like `openValues` it works without knowing the `Editor`.
*/ */
openAsset(assetId: string): void { openAsset(assetId: string): void {
this.select({ kind: 'asset', assetId }); this.select({ kind: 'asset', assetId });
+189
View File
@@ -0,0 +1,189 @@
/**
* The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior,
* with the very rows the inspector draws for them – or one list, as deep inside them as it sits.
* One editor for every such tab, pointed at the target of the one showing, like the asset editors.
*/
import type { Container } from 'pixi.js';
import { findObject } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
import type { InspectorHost } from '../inspector/host';
import { viewOf } from '../inspector/objectQueries';
import { buildOwnRows, objectSignature, refreshObjectSection } from '../inspector/objectSection';
import { InlineList, ListHead } from '../list/InlineList';
import { type LiveVariables, resolveList } from '../list/slot';
import type { EditorState, ValuesTarget } from '../state';
/** The asset or placed object whose variables `target` shows, when it still has any to show. */
function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'variables' }>) {
const { owner } = target;
if (owner.kind === 'asset') {
const asset = findAsset(state.project, owner.assetId);
return asset?.type === 'behavior' || asset?.type === 'variables' ? { asset, name: asset.name } : null;
}
const view = viewOf(state.project, owner.holderId);
const object = view && findObject(view, owner.objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset?.type === 'behavior' ? { asset, name: object.name || asset.name } : null;
}
/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */
export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null {
if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null;
const owner = ownerOf(state, target);
return owner ? `${owner.name} · Variables` : null;
}
export class VariablesEditor extends VStack {
private readonly title = new Label('', { style: 'bodyBold' });
private readonly scroll = new ScrollContainer();
private host: InspectorHost;
private target: ValuesTarget | null = null;
private signature = '';
private lastLiveRefresh = 0;
private rebuildWhenLeft = false;
private readonly releaseFocusHandler: () => void;
/** The list a list target draws, and its head. */
private list: { head: ListHead; items: InlineList } | null = null;
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: LiveVariables = () => null;
constructor(private readonly state: EditorState) {
super({ gap: 0 });
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + 2 * Theme.padding;
header.add(this.title, 1);
this.add(header);
this.host = this.emptyHost();
this.scroll.setContent(this.host.body);
this.add(this.scroll, 1);
state.on('project', () => this.refresh());
state.on('runtime', () => this.refreshLive());
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
}
/** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<VStack['destroy']>[0]): void {
this.releaseFocusHandler();
super.destroy(options);
}
/** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */
show(target: ValuesTarget): void {
this.target = target;
this.rebuild();
}
/** For the check scripts: what the tab shows, and the lists drawn in it. */
debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } {
return {
title: this.title.text,
target: this.target,
rows: [...this.host.fields.keys()],
};
}
/** Every list drawn in the tab, at any depth. */
lists(): InlineList[] {
return inlineListsIn(this.host.body);
}
private emptyHost(): InspectorHost {
return {
state: this.state,
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
private currentSignature(): string {
const target = this.target;
if (!target) return '';
if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing';
const { owner } = target;
if (!ownerOf(this.state, target)) return 'missing';
return owner.kind === 'asset'
? assetSignature(this.state.project, owner.assetId)
: objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId);
}
private rebuild(): void {
this.rebuildWhenLeft = false;
this.host.body.destroy({ children: true });
this.host = this.emptyHost();
this.list = null;
this.signature = this.currentSignature();
const target = this.target;
this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? '');
if (target?.kind === 'list') this.buildList(target.slot);
else if (target) {
const owner = ownerOf(this.state, target);
if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true);
else if (target.owner.kind === 'object')
buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true);
}
this.scroll.setContent(this.host.body);
}
private buildList(slot: Extract<ValuesTarget, { kind: 'list' }>['slot']): void {
const access = () => resolveList(this.state, slot, this.liveVariables);
const items = new InlineList(this.host, access);
const head = new ListHead(access, (kind) => items.addItem(kind), false);
items.head = head;
this.host.body.add(head);
this.host.body.add(items);
this.list = { head, items };
}
/** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */
private refreshLive(): void {
if (!this.visible) return;
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
if (ui().focus.isWithin(this)) return;
this.lastLiveRefresh = now;
this.refresh();
}
private refresh(): void {
const target = this.target;
if (!target) return;
this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? '');
if (this.currentSignature() !== this.signature) {
if (ui().focus.isWithin(this)) {
this.rebuildWhenLeft = true;
return;
}
this.rebuild();
return;
}
if (target.kind === 'list') {
this.list?.head.setCount(this.list.items.access()?.value.length ?? 0);
this.list?.items.refresh();
return;
}
const { owner } = target;
const ok =
owner.kind === 'asset'
? refreshAssetSection(this.host, owner.assetId)
: refreshObjectSection(this.host, owner.holderId, owner.objectId);
if (!ok) this.rebuild();
}
}
/** Every inline list below `root`, outermost first – what the check scripts pick one from. */
export function inlineListsIn(root: Container): InlineList[] {
const found: InlineList[] = [];
const walk = (node: Container): void => {
if (node instanceof InlineList) found.push(node);
for (const child of node.children) walk(child);
};
walk(root);
return found;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* The rules of the Variables tab (#252) that need no widget: which document a target is, and how
* much of a list is drawn inline.
*/
import type { ListSlot, ValuesTarget, VariablesOwner } from '../state';
/**
* A key that is the same for the same target however it was built – the document id of its tab,
* so asking twice for one list brings up its tab instead of opening a second.
*/
export function valuesKey(target: ValuesTarget): string {
if (target.kind === 'variables') return `vars|${ownerKey(target.owner)}`;
return `list|${slotKey(target.slot)}`;
}
function ownerKey(owner: VariablesOwner): string {
return owner.kind === 'asset' ? `asset|${owner.assetId}` : `object|${owner.holderId}|${owner.objectId}`;
}
function slotKey(slot: ListSlot): string {
const where =
slot.kind === 'asset'
? `asset|${slot.assetId}`
: slot.kind === 'object'
? `object|${slot.holderId}|${slot.objectId}`
: `node|${slot.assetId}|${slot.nodeId}`;
return `${where}|${JSON.stringify([slot.key, ...(slot.path ?? [])])}`;
}
/** The list one step further down: `inner` leads from the slot's list to the one inside it. */
export function deeperSlot(slot: ListSlot, inner: ListSlot['path']): ListSlot {
return { ...slot, path: [...(slot.path ?? []), ...(inner ?? [])] };
}
/** How many items the inspector draws of a list before it says "… N more". */
export const INLINE_LIMIT = 20;
/** The items drawn and the ones left out: all of them without a limit. */
export function shownItems(count: number, limit = Infinity): { shown: number; more: number } {
const shown = Math.min(count, Math.max(0, limit));
return { shown, more: count - shown };
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* The address the List tab edits through. What matters here is not that a list can be read, but * The address an inline list edits through (#252). What matters here is not that a list can be read, but
* that writing one item goes through the same command (and the same undo) as the inspector row * 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*. * it replaces, and that a row keeps answering for the list as it is *now*.
*/ */
+17 -11
View File
@@ -1,5 +1,5 @@
/** /**
* Editing a value three levels deep (#239): the List tab reaches a nested list by a path of keys * Editing a value three levels deep (#239): an inline list reaches a nested list by a path of keys
* and indices, an inspector row a nested struct field by its keys. Either way one edit is one * and indices, an inspector row a nested struct field by its keys. Either way one edit is one
* command on the whole value, so undo takes back exactly that edit and the neighbours stay. * command on the whole value, so undo takes back exactly that edit and the neighbours stay.
*/ */
@@ -77,15 +77,22 @@ describe('a list nested in the items of a list', () => {
expect(props(f).rows).toEqual(rows); expect(props(f).rows).toEqual(rows);
}); });
it('opens the tab one level further down', () => { it('opens a tab one level further down', () => {
at([0, 'drops'])!.openList([1, 'x']); at([0, 'drops'])!.openList!([1, 'x']);
expect(f.state.listSlot).toEqual({ expect(f.state.openedValues).toEqual({
kind: 'asset', kind: 'list',
assetId: f.id, slot: { kind: 'asset', assetId: f.id, key: 'rows', path: [0, 'drops', 1, 'x'] },
key: 'rows',
path: [0, 'drops', 1, 'x'],
}); });
}); });
it('reaches a list inside it for drawing it inline, writing the whole value', () => {
const inner = at([0, 'drops'])!.sub([1, 'x'])!;
expect(inner.title).toBe(at([0, 'drops', 1, 'x'])!.title);
expect(inner.value).toEqual([2, 3]);
inner.replace([2, 3, 4]);
expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 3, 4] }] }, 'plain']);
expect(at([0, 'drops'])!.sub([0])).toBe(null);
});
}); });
describe('a field of a struct declared inside a struct', () => { describe('a field of a struct declared inside a struct', () => {
@@ -125,9 +132,8 @@ describe('a field of a struct declared inside a struct', () => {
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } }); expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } });
}); });
it('opens a list field in the List tab by its path', () => { it('draws a list field inline by its path', () => {
handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!(); const list = handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).list!()!;
const list = resolveList(f.state, f.state.listSlot!)!;
expect(list.title).toBe('B · enemy · loot · drops'); expect(list.title).toBe('B · enemy · loot · drops');
list.replace(['a', ['b']]); list.replace(['a', ['b']]);
expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } }); expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } });
+141
View File
@@ -0,0 +1,141 @@
/**
* The rules behind the Variables tab (#252): which document a target is – the same list asked for
* twice is one tab, a list inside it another – what the tab says, and how much of a list the
* inspector draws.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, RenameSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { resolveList } from '../../src/editor/list/slot';
import { EditorState, type ListSlot, type ValuesTarget } from '../../src/editor/state';
import { deeperSlot, INLINE_LIMIT, shownItems, valuesKey } from '../../src/editor/variables/target';
import { valuesTitle } from '../../src/editor/variables/VariablesEditor';
function setup() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'Pickup');
expect(state.execute(create)).toBe(true);
const id = create.asset.id;
expect(state.execute(new AddVariableCommand(id, 'loots', 'list'))).toBe(true);
return { state, id };
}
describe('the document of a Variables tab', () => {
const slot: ListSlot = { kind: 'asset', assetId: 'a', key: 'loots' };
it('is the same for the same list, however the address was built', () => {
const built = { key: 'loots', assetId: 'a', kind: 'asset', path: [] } as const;
expect(valuesKey({ kind: 'list', slot })).toBe(valuesKey({ kind: 'list', slot: built }));
expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) })).toBe(
valuesKey({ kind: 'list', slot: { ...slot, path: [2] } }),
);
});
it('is another one for a list inside it, so opening that one adds a tab', () => {
const outer = valuesKey({ kind: 'list', slot });
const inner = valuesKey({ kind: 'list', slot: deeperSlot(slot, [2, 'drops']) });
expect(inner).not.toBe(outer);
// A key and an index that read alike are still two steps.
expect(valuesKey({ kind: 'list', slot: deeperSlot(slot, ['2']) })).not.toBe(
valuesKey({ kind: 'list', slot: deeperSlot(slot, [2]) }),
);
});
it('keeps the variables of an owner apart from a list of it and from another owner', () => {
const asset: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: 'a' } };
const object: ValuesTarget = {
kind: 'variables',
owner: { kind: 'object', holderId: 's', objectId: 'a' },
};
const keys = new Set([asset, object, { kind: 'list', slot } as const].map(valuesKey));
expect(keys.size).toBe(3);
});
});
describe('how much of a list the inspector draws', () => {
it('draws everything up to the limit and counts the rest', () => {
expect(shownItems(5, INLINE_LIMIT)).toEqual({ shown: 5, more: 0 });
expect(shownItems(INLINE_LIMIT, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 0 });
expect(shownItems(23, INLINE_LIMIT)).toEqual({ shown: INLINE_LIMIT, more: 3 });
});
it('draws all of it without a limit – the tab', () => {
expect(shownItems(500)).toEqual({ shown: 500, more: 0 });
});
});
describe('what a Variables tab says', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => (f = setup()));
it('names the asset, the placed object and the list', () => {
expect(valuesTitle(f.state, { kind: 'variables', owner: { kind: 'asset', assetId: f.id } })).toBe(
'Pickup · Variables',
);
const createScene = new CreateAssetCommand('scene', 'Level');
expect(f.state.execute(createScene)).toBe(true);
const scene = createScene.asset.id;
const place = new PlaceAssetCommand(scene, f.id, { x: 0, y: 0 });
expect(f.state.execute(place)).toBe(true);
const object: ValuesTarget = {
kind: 'variables',
owner: { kind: 'object', holderId: scene, objectId: place.objectId },
};
expect(valuesTitle(f.state, object)).toBe('Pickup · Variables');
expect(f.state.execute(new RenameSceneObjectCommand(scene, place.objectId, 'Coin 3'))).toBe(true);
expect(valuesTitle(f.state, object)).toBe('Coin 3 · Variables');
const slot: ListSlot = { kind: 'asset', assetId: f.id, key: 'loots' };
expect(valuesTitle(f.state, { kind: 'list', slot })).toBe('Pickup · loots');
});
it('says nothing once the owner is gone – the tab closes then', () => {
const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: f.id } };
const list: ValuesTarget = { kind: 'list', slot: { kind: 'asset', assetId: f.id, key: 'loots' } };
expect(f.state.execute(new DeleteAssetCommand(f.id))).toBe(true);
expect(valuesTitle(f.state, target)).toBe(null);
expect(valuesTitle(f.state, list)).toBe(null);
});
it('has no Variables tab for an asset that declares none', () => {
const texture = new CreateAssetCommand('texture', 'T');
expect(f.state.execute(texture)).toBe(true);
const target: ValuesTarget = { kind: 'variables', owner: { kind: 'asset', assetId: texture.asset.id } };
expect(valuesTitle(f.state, target)).toBe(null);
});
});
describe('opening a list in a tab', () => {
let f: ReturnType<typeof setup>;
beforeEach(() => (f = setup()));
it('asks for a tab on the list itself, or on one inside it', () => {
const list = resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'loots' })!;
list.replace([[1, 2]]);
let asked = 0;
f.state.on('values', () => (asked += 1));
list.openList!([]);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [] },
});
list.openList!([0]);
expect(f.state.openedValues).toEqual({
kind: 'list',
slot: { kind: 'asset', assetId: f.id, key: 'loots', path: [0] },
});
expect(asked).toBe(2);
});
it('offers no tab for a list a node holds inline', () => {
const add = new AddNodeCommand(f.id, 'List', 0, 0);
expect(f.state.execute(add)).toBe(true);
const node = resolveList(f.state, { kind: 'node', assetId: f.id, nodeId: add.node.id, key: 'value' })!;
expect(node.openList).toBeUndefined();
node.replace([[1]]);
expect(node.sub([0])!.openList).toBeUndefined();
});
});
+306 -145
View File
@@ -1,12 +1,14 @@
/** /**
* The List tab: a list property is a button saying how many items it has, clicking it opens the * Lists inline (#252): a list's items stand right under its row in the inspector, a row per item
* tab, and there a row per item edits, adds and removes them with the inspector's own widgets. * edited with the inspector's own widgets, and a list inside a list is drawn inline again. The head
* of a list opens it in a Variables tab – a list opened from inside such a tab gets a tab of its own
* beside it – and the VARIABLES header opens every variable of an asset or a placed object there.
*/ */
import { clickDocumentTab, MAKE_OBJECT } from './lib/scaffold.mjs';
export async function listTab(t) { export async function listTab(t) {
const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t; const { check, sleep, json, find, shot, click, clickText, press, typeText, mutate, undo } = t;
/** `locate` throws when the text is absent, which is the wrong shape for "is it there?". */
/** /**
* A kind menu entry, always the *last* drawn one: a menu is drawn on top, and the kind names are * A kind menu entry, always the *last* drawn one: a menu is drawn on top, and the kind names are
* ordinary words ("Text") that the panels behind it may carry too – which is what made this click * ordinary words ("Text") that the panels behind it may carry too – which is what made this click
@@ -17,6 +19,21 @@ export async function listTab(t) {
await click(at.x, at.y); await click(at.x, at.y);
}; };
/** The hooks of one inline list, found by its title in the inspector or in the Variables tab. */
const hook = (fn, where, title, ...args) =>
json(`__gm.values.${fn}(${[where, title, ...args].map((a) => JSON.stringify(a)).join(', ')})`);
const listInfo = async (title, where = 'inspector') =>
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === title) ?? null;
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
};
const valueDocs = async () => (await json('__gm.documents()')).docs.filter((d) => d.startsWith('values:'));
const closeValueDocs = async () => {
for (const doc of await valueDocs()) await mutate(`__gm.closeDocument(${JSON.stringify(doc)})`);
};
const NUMS = 'Listy · nums';
// A throwaway behavior carrying one list variable; removed again at the end of the step. Its // 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. // items are two digits so that no value can be confused with a row's index label.
await mutate(` await mutate(`
@@ -32,41 +49,35 @@ export async function listTab(t) {
__gm.state.select({ kind: 'asset', assetId: 'listy' }); __gm.state.select({ kind: 'asset', assetId: 'listy' });
`); `);
// 21a. The inspector shows the count, not a comma-separated line. // 21a. The inspector shows the count and, under it, the items themselves.
const button = await find('3 items'); check('a list property reads as its item count', (await find('3 items')) !== null);
check('a list property reads as its item count', button !== null, JSON.stringify(button)); let info = await listInfo(NUMS);
check(
'and its items stand right under it, a row each',
info?.rows === 3 && JSON.stringify(info.items) === '["30","11","44"]',
JSON.stringify(info),
);
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene'); check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
await shot('01-count-button'); await shot('01-inline');
await click(button.x, button.y);
check('clicking it switches to the List tab', (await json('__gm.tab()')) === 'list');
let info = await json('__gm.list.info()');
check('which shows the list it was opened from', info.title === 'Listy · nums', JSON.stringify(info));
check('with a row per item', JSON.stringify(info.items) === '["30","11","44"]', JSON.stringify(info.items));
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
await shot('02-list-tab');
// 21b. A row edits its item, and it is one undo step. // 21b. A row edits its item, and it is one undo step.
const second = await json('__gm.list.fieldAt(1)'); await press_(await hook('fieldAt', 'inspector', NUMS, 1), 'item 1');
await click(second.x, second.y);
await typeText('42'); await typeText('42');
await press('Enter'); await press('Enter');
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]); check('typing in a row writes that item', JSON.stringify(info.items) === '["30","42","44"]', info.items[1]);
await undo(); await undo();
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]); check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
// 21b-ii. An item edit does not rebuild the rows, so a delete clicked after it must build on the // 21b-ii. An item edit does not rebuild the rows, so a delete clicked after it must build on the
// edited list – with the one the rows were built from, the edit came back undone (#235). // edited list – with the one the rows were built from, the edit came back undone (#235).
const edited = await json('__gm.list.fieldAt(2)'); await press_(await hook('fieldAt', 'inspector', NUMS, 2), 'item 2');
await click(edited.x, edited.y);
await typeText('66'); await typeText('66');
await press('Enter'); await press('Enter');
const removeFirst = await json('__gm.list.removeAt(0)'); await press_(await hook('removeAt', 'inspector', NUMS, 0), 'remove 0');
await click(removeFirst.x, removeFirst.y);
await sleep(200); await sleep(200);
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check( check(
'deleting a row keeps an edit made to another row before', 'deleting a row keeps an edit made to another row before',
JSON.stringify(info.items) === '["11","66"]', JSON.stringify(info.items) === '["11","66"]',
@@ -74,47 +85,75 @@ export async function listTab(t) {
); );
await undo(); await undo();
await undo(); await undo();
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check( check(
'and two undos take delete and edit back', 'and two undos take delete and edit back',
JSON.stringify(info.items) === '["30","11","44"]', JSON.stringify(info.items) === '["30","11","44"]',
info.items, info.items,
); );
// 21c. A short list opened after a long one. Its rows leave the content height pinned to the // 21c. A long list: the inspector draws the first 20 and says how many more there are; that line
// viewport, so a layout skipped on "same size" would stack them all at the top. // opens the whole list in a tab.
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy'); const a = __gm.state.project.assets.find((x) => x.id === 'listy');
a.props.nums = Array.from({ length: 60 }, (_, i) => i); a.props.nums = Array.from({ length: 60 }, (_, i) => i);
__gm.state.preview(); __gm.state.preview();
`); `);
const longRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; await sleep(150);
check('a long list stacks its rows', longRows[0].y !== longRows[1].y, JSON.stringify(longRows)); info = await listInfo(NUMS);
const longRows = [await hook('fieldAt', 'inspector', NUMS, 0), await hook('fieldAt', 'inspector', NUMS, 1)];
check(
'a long list draws 20 rows in the inspector and counts the other 40',
info.rows === 20 && info.more === 40 && longRows[0].y !== longRows[1].y,
JSON.stringify({ rows: info.rows, more: info.more, longRows }),
);
await shot('02-more');
// The line sits below the inspector's fold: scrolled to with the wheel, as a user gets there.
await t.wheel(longRows[0].x - 140, longRows[0].y, 2000);
await sleep(150);
await press_(await hook('moreAt', 'inspector', NUMS), '… more');
await sleep(200);
const tabList = await listInfo(NUMS, 'tab');
check(
'clicking "… 40 more" opens the list in a Variables tab, every item drawn',
(await json('__gm.tab()')) === 'variables' && tabList?.rows === 60 && tabList.more === 0,
JSON.stringify({ tab: await json('__gm.tab()'), tabList }),
);
check(
'titled by the list, the inspector still beside it',
(await json('__gm.values.info()')).title === NUMS && (await find('VARIABLES')) !== null,
(await json('__gm.values.info()')).title,
);
await shot('03-tab');
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy'); const a = __gm.state.project.assets.find((x) => x.id === 'listy');
a.props.nums = [30, 11, 44]; a.props.nums = [30, 11, 44];
__gm.state.preview(); __gm.state.preview();
`); `);
const shortRows = [await json('__gm.list.fieldAt(0)'), await json('__gm.list.fieldAt(1)')]; await sleep(150);
const shortRows = [await hook('fieldAt', 'tab', NUMS, 0), await hook('fieldAt', 'tab', NUMS, 1)];
check( check(
'and a short list after it still stacks its own', 'and a short list after it still stacks its rows',
shortRows[0].y !== shortRows[1].y, (await listInfo(NUMS, 'tab')).rows === 3 && shortRows[0].y !== shortRows[1].y,
JSON.stringify(shortRows), JSON.stringify(shortRows),
); );
await closeValueDocs();
// 21d. `+ Item` asks for a kind, since the list does not hold one for it. // 21d. `+ Item` asks for a kind, since the list does not hold one for it.
await clickText('+ Item'); const addTo = async (title, where = 'inspector') =>
press_((await hook('headAt', where, title))?.add, '+ Item');
await addTo(NUMS);
check( check(
'a list of its own offers every kind, list and struct included', 'a list of its own offers every kind, list and struct included',
(await json('__gm.list.info()')).offers.join(',') === (await listInfo(NUMS)).offers.join(',') ===
// In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there, // In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there,
// and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239. // and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239.
'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct', 'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct',
JSON.stringify((await json('__gm.list.info()')).offers), JSON.stringify((await listInfo(NUMS)).offers),
); );
await pickKind('Text'); await pickKind('Text');
await sleep(200); await sleep(200);
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check('+ Item appends one of the kind picked', info.items.length === 4, JSON.stringify(info.items)); check('+ Item appends one of the kind picked', info.items.length === 4, JSON.stringify(info.items));
check( check(
'and that row holds that kind', 'and that row holds that kind',
@@ -122,77 +161,108 @@ export async function listTab(t) {
info.kinds.join(','), info.kinds.join(','),
); );
await undo(); await undo();
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3); await sleep(150);
check('undo removes it again', (await listInfo(NUMS)).items.length === 3);
// 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs. // 21d-ii. An object item holds nothing in the file – only its place, filled while a game runs.
await clickText('+ Item'); await addTo(NUMS);
await pickKind('Object'); await pickKind('Object');
await sleep(200); await sleep(200);
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check( check(
'an Object item is added as the empty object', 'an Object item is added as the empty object',
info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '', info.kinds.join(',') === 'number,number,number,object' && info.items[3] === '',
JSON.stringify(info), JSON.stringify(info),
); );
await shot('03a-object-item');
await undo(); await undo();
await sleep(150);
// 21d-iii. An item may be a list again (#239): its row is an item count, and a click opens the // 21d-iii. An item may be a list again (#239): it is drawn inline under its row, one step in, the
// List tab on that list, one level down, with the path in the title. // path in its title – and opened from a tab, it gets a tab of its own beside that one.
const INNER = `${NUMS} · [3]`;
const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums"); const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums");
await clickText('+ Item'); await addTo(NUMS);
await pickKind('List'); await pickKind('List');
await sleep(200); await sleep(200);
info = await json('__gm.list.info()'); info = await listInfo(NUMS);
check( check(
'a List item is added as an empty list', 'a List item is added as an empty list',
info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]', info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]',
JSON.stringify(info), JSON.stringify(info),
); );
const innerCount = await json('__gm.list.fieldAt(3)'); check('and drawn inline, the path in its title', (await listInfo(INNER))?.rows === 0);
await click(innerCount.x, innerCount.y); await addTo(INNER);
await sleep(200);
info = await json('__gm.list.info()');
check(
'clicking its count opens that list, the path in the title',
info.title.endsWith('· nums · [3]') && info.items.length === 0,
JSON.stringify(info),
);
await clickText('+ Item');
await pickKind('Number'); await pickKind('Number');
await sleep(200); await sleep(200);
check( check(
'an item added there lands in the list inside the list', 'an item added there lands in the list inside the list',
JSON.stringify((await nums()).slice(3)) === '[[0]]', JSON.stringify((await nums()).slice(3)) === '[[0]]' && (await listInfo(INNER))?.rows === 1,
JSON.stringify(await nums()), JSON.stringify(await nums()),
); );
await shot('03b-nested-list'); const outerRow = await hook('fieldAt', 'inspector', NUMS, 2);
await undo(); const innerRow = await hook('fieldAt', 'inspector', INNER, 0);
await undo();
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
await shot('03-rows');
// 21e. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)');
check( check(
'closing the list closes its tab', 'its row sits below the outer rows, pushed further right',
(await json('__gm.tab()')) !== 'list' && !(await json('__gm.documents()')).docs.includes('list'), innerRow.y > outerRow.y && innerRow.x > outerRow.x - 1,
JSON.stringify({ tab: await json('__gm.tab()'), docs: await json('__gm.documents()') }), JSON.stringify({ outerRow, innerRow }),
); );
await shot('04-nested-inline');
await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
await sleep(200);
const outerDoc = (await json('__gm.documents()')).active;
check(
'Open on the head opens the list in a tab, with the inner list inline in it',
(await json('__gm.values.info()')).title === NUMS && (await listInfo(INNER, 'tab'))?.rows === 1,
JSON.stringify(await json('__gm.values.info()')),
);
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list');
await sleep(200);
let docs = await json('__gm.documents()');
check(
'Open on a list inside the tab opens a second tab and leaves the first',
(await valueDocs()).length === 2 &&
docs.docs.includes(outerDoc) &&
docs.active !== outerDoc &&
(await json('__gm.values.info()')).title === INNER,
JSON.stringify({ docs, title: (await json('__gm.values.info()')).title }),
);
await shot('05-second-tab');
await clickDocumentTab(t, outerDoc);
await sleep(150);
await press_((await hook('headAt', 'tab', INNER)).open, 'Open of the inner list, again');
await sleep(150);
docs = await json('__gm.documents()');
check(
'asking again for a list that has its tab brings that tab up, no third one',
(await valueDocs()).length === 2 && (await json('__gm.values.info()')).title === INNER,
JSON.stringify(docs),
);
await undo();
await undo();
await sleep(150);
check(
'undo empties the inner list first, then takes it away – and its tab with it',
(await valueDocs()).length === 1 && JSON.stringify(await nums()) === '[30,11,44]',
JSON.stringify({ docs: await valueDocs(), nums: await nums() }),
);
await closeValueDocs();
// 21e. The count follows the value.
await mutate(` await mutate(`
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2]; __gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
__gm.state.preview(); __gm.state.preview();
`); `);
check('and the count follows the value', (await find('2 items')) !== null); await sleep(150);
check(
'the count and the rows follow the value',
(await find('2 items')) !== null && (await listInfo(NUMS)).rows === 2,
);
// 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of // 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
// it is the picker, and picking changes that one item and no other. // it is the picker, and picking changes that one item and no other.
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`); const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
let mark = await json('__gm.list.markAt(0)');
await click(mark.x, mark.y);
await pickKind('Text'); await pickKind('Text');
await sleep(250); await sleep(250);
let listy = await numsOf(); let listy = await numsOf();
@@ -202,9 +272,9 @@ export async function listTab(t) {
JSON.stringify(listy.props.nums), JSON.stringify(listy.props.nums),
); );
check( check(
'and the tab reads the two kinds off the values', 'and the list reads the two kinds off the values',
(await json('__gm.list.info()')).kinds.join(',') === 'text,number', (await listInfo(NUMS)).kinds.join(',') === 'text,number',
JSON.stringify((await json('__gm.list.info()')).kinds), JSON.stringify((await listInfo(NUMS)).kinds),
); );
check('the variable itself says nothing about items', listy.variables[0].items === undefined); check('the variable itself says nothing about items', listy.variables[0].items === undefined);
await undo(); await undo();
@@ -216,8 +286,8 @@ export async function listTab(t) {
); );
// 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged. // 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
mark = await json('__gm.list.markAt(1)'); await sleep(150);
await click(mark.x, mark.y); await press_(await hook('markAt', 'inspector', NUMS, 1), 'mark 1');
await pickKind('Color'); await pickKind('Color');
await sleep(250); await sleep(250);
listy = await numsOf(); listy = await numsOf();
@@ -228,11 +298,12 @@ export async function listTab(t) {
); );
check( check(
'and is read back as a colour, its neighbour still a number', 'and is read back as a colour, its neighbour still a number',
(await json('__gm.list.info()')).kinds.join(',') === 'number,color', (await listInfo(NUMS)).kinds.join(',') === 'number,color',
JSON.stringify((await json('__gm.list.info()')).kinds), JSON.stringify((await listInfo(NUMS)).kinds),
); );
await shot('03b-kinds'); await shot('06-kinds');
await undo(); await undo();
await sleep(150);
// 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text. // 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
// The row carries the picker the `texture` property has, frames of a sheet included. // The row carries the picker the `texture` property has, frames of a sheet included.
@@ -250,8 +321,7 @@ export async function listTab(t) {
}); });
__gm.state.preview(); __gm.state.preview();
`); `);
mark = await json('__gm.list.markAt(0)'); await press_(await hook('markAt', 'inspector', NUMS, 0), 'mark 0');
await click(mark.x, mark.y);
await pickKind('Texture'); await pickKind('Texture');
await sleep(250); await sleep(250);
// Switching kind carries the value along, here into an id that resolves to nothing – the same as // Switching kind carries the value along, here into an id that resolves to nothing – the same as
@@ -261,8 +331,7 @@ export async function listTab(t) {
JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]', JSON.stringify((await numsOf()).props.nums) === '[{"$texture":"1"},2]',
JSON.stringify((await numsOf()).props.nums), JSON.stringify((await numsOf()).props.nums),
); );
const pic = await json('__gm.list.fieldAt(0)'); await press_(await hook('fieldAt', 'inspector', NUMS, 0), 'picture 0');
await click(pic.x, pic.y);
check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null); check('the row carries the picture picker, sheet frames and all', (await find('Sheet / Frame 1')) !== null);
await pickKind('Sheet / Frame 1'); await pickKind('Sheet / Frame 1');
await sleep(250); await sleep(250);
@@ -272,11 +341,10 @@ export async function listTab(t) {
JSON.stringify((await numsOf()).props.nums), JSON.stringify((await numsOf()).props.nums),
); );
check( check(
'which the tab reads back as a texture, its neighbour still a number', 'which the list reads back as a texture, its neighbour still a number',
(await json('__gm.list.info()')).kinds.join(',') === 'texture,number', (await listInfo(NUMS)).kinds.join(',') === 'texture,number',
JSON.stringify((await json('__gm.list.info()')).kinds), JSON.stringify((await listInfo(NUMS)).kinds),
); );
await shot('03c-texture');
await undo(); await undo();
await undo(); await undo();
check( check(
@@ -291,8 +359,7 @@ export async function listTab(t) {
__gm.state.preview(); __gm.state.preview();
`); `);
await mutate('__gm.state.openList(null)'); // The mark of a list is plain, and still may not push the value off the inspector's grid.
// The mark of a list is plain now, and still may not push the value off the inspector's grid.
const lefts = await json( const lefts = await json(
`[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`, `[__gm.inspectorField('Name')?.left ?? null, __gm.inspectorField('nums')?.left ?? null]`,
); );
@@ -302,21 +369,26 @@ export async function listTab(t) {
JSON.stringify(lefts), JSON.stringify(lefts),
); );
// 21i. A list whose target is gone empties the tab rather than showing a stale one. // 21i. A list whose owner is gone takes its tab along rather than showing a stale one.
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); await press_((await hook('headAt', 'inspector', NUMS)).open, 'Open of nums');
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list'); await sleep(150);
check('the list is open in a tab', (await valueDocs()).length === 1);
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'listy'); p.assets = p.assets.filter((a) => a.id !== 'listy');
__gm.state.select(null); __gm.state.select(null);
__gm.state.preview(); __gm.state.preview();
`); `);
info = await json('__gm.list.info()'); await sleep(150);
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title); check(
await shot('04-empty'); 'a deleted owner closes the tab',
(await valueDocs()).length === 0 && (await json('__gm.tab()')) !== 'variables',
JSON.stringify({ docs: await valueDocs(), tab: await json('__gm.tab()') }),
);
// 21j. A list whose items are structs: each row is the bundle's fields under each other, built by // 21j. A list whose items are structs: each row is the bundle's fields under each other. Values
// the inspector's own struct renderer. Values are two digits so no row's index can match one. // are two digits so no row's index can match one.
const CELLS = 'Celly · cells';
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets.push({ p.assets.push({
@@ -328,54 +400,50 @@ export async function listTab(t) {
graph: { nodes: [], edges: [] }, graph: { nodes: [], edges: [] },
}); });
__gm.state.select({ kind: 'asset', assetId: 'celly' }); __gm.state.select({ kind: 'asset', assetId: 'celly' });
__gm.state.openList({ kind: 'asset', assetId: 'celly', key: 'cells' });
`); `);
info = await json('__gm.list.info()'); await sleep(150);
check('a list of structs opens with a row per bundle', info.items.length === 2, JSON.stringify(info)); info = await listInfo(CELLS);
check(
'a list of structs draws a row per bundle',
info?.items.length === 2 && info.rows === 2,
JSON.stringify(info),
);
check( check(
'and a row reads as its fields, not as an object', 'and a row reads as its fields, not as an object',
info.items[0] === 'x: 30, y: 11', info.items[0] === 'x: 30, y: 11',
JSON.stringify(info.items), JSON.stringify(info.items),
); );
check( check('both items read as structs', info.kinds.join(',') === 'struct,struct', info.kinds.join(','));
'the tab reads both items as structs',
info.kinds.join(',') === 'struct,struct',
info.kinds.join(','),
);
check('the field labels of a bundle are on screen', (await find('x')) !== null); check('the field labels of a bundle are on screen', (await find('x')) !== null);
await shot('05-struct-rows'); await shot('07-struct-rows');
// Editing one field must leave the others and the neighbouring item alone – the whole item is // Editing one field must leave the others and the neighbouring item alone – the whole item is
// rewritten on every keystroke, so a snapshot anywhere would silently revive an old value. // rewritten on every keystroke, so a snapshot anywhere would silently revive an old value.
const firstField = await json("__gm.list.fieldAt(0, 'x')"); await press_(await hook('fieldAt', 'inspector', CELLS, 0, 'x'), 'field x');
await click(firstField.x, firstField.y);
await typeText('77'); await typeText('77');
await press('Enter'); await press('Enter');
const cells = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`); const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check( check(
'editing one field of one item leaves every other value untouched', 'editing one field of one item leaves every other value untouched',
JSON.stringify(cells) === JSON.stringify(await cellsOf()) ===
JSON.stringify([ JSON.stringify([
{ x: 77, y: 11 }, { x: 77, y: 11 },
{ x: 44, y: 55 }, { x: 44, y: 55 },
]), ]),
JSON.stringify(cells), JSON.stringify(await cellsOf()),
); );
await undo(); await undo();
const back = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check( check(
'and undo takes back just that field', 'and undo takes back just that field',
JSON.stringify(back) === JSON.stringify(await cellsOf()) ===
JSON.stringify([ JSON.stringify([
{ x: 30, y: 11 }, { x: 30, y: 11 },
{ x: 44, y: 55 }, { x: 44, y: 55 },
]), ]),
JSON.stringify(back), JSON.stringify(await cellsOf()),
); );
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – and // 21k. A struct item carries its own shape, so it is named, typed and grown on its own row.
// primitives only, because a list inside a list item is the one shape the model does not store.
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null); check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
await clickText('+ Field'); await clickText('+ Field');
await pickKind('Boolean'); await pickKind('Boolean');
@@ -389,22 +457,16 @@ export async function listTab(t) {
]), ]),
JSON.stringify(await cellsOf()), JSON.stringify(await cellsOf()),
); );
info = await json('__gm.list.info()'); info = await listInfo(CELLS);
check( check('and the row shows the new field', info.items[0] === 'x: 30, y: 11, field1: false', info.items[0]);
'and the tab shows the new field on that row',
info.items[0] === 'x: 30, y: 11, field1: false',
info.items[0],
);
// Asked in the page: `undefined` comes back over the wire as `null`. // Asked in the page: `undefined` comes back over the wire as `null`.
check( check(
'the inspector has no item shape to show any more', 'the variable has no item shape to show',
await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`), await json(`__gm.state.project.assets.find((a) => a.id === 'celly').variables[0].fields === undefined`),
); );
await shot('06-item-field');
// A field of a struct item is renamed and retyped where it sits, both being plain value edits. // A field of a struct item is renamed and retyped where it sits, both being plain value edits.
const nameBox = await json("__gm.list.nameAt(0, 'y')"); await press_(await hook('nameAt', 'inspector', CELLS, 0, 'y'), 'name y');
await click(nameBox.x, nameBox.y);
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText('row'); await typeText('row');
await press('Enter'); await press('Enter');
@@ -417,8 +479,8 @@ export async function listTab(t) {
]), ]),
JSON.stringify(await cellsOf()), JSON.stringify(await cellsOf()),
); );
const fieldMark = await json("__gm.list.markAt(0, 'x')"); await sleep(150);
await click(fieldMark.x, fieldMark.y); await press_(await hook('markAt', 'inspector', CELLS, 0, 'x'), 'mark x');
await pickKind('Color'); await pickKind('Color');
await sleep(250); await sleep(250);
check( check(
@@ -439,8 +501,8 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()), JSON.stringify(await cellsOf()),
); );
// 21l. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count // 21l. A node's unconnected `list` input lives in the same params bag as a setting: inline only,
// button too. // no tab to open it in.
await mutate(` await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly'); const a = __gm.state.project.assets.find((x) => x.id === 'celly');
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} }); a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
@@ -448,22 +510,22 @@ export async function listTab(t) {
__gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] }); __gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] });
__gm.state.preview(); __gm.state.preview();
`); `);
const portButton = await find('0 items'); await sleep(150);
check('an unconnected list input reads as an item count', portButton !== null); const portHead = await hook('headAt', 'inspector', 'Count · list');
await click(portButton.x, portButton.y); check(
check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list'); 'an unconnected list input is drawn inline, without an Open',
info = await json('__gm.list.info()'); (await find('0 items')) !== null && portHead !== null && portHead.open === null,
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title); JSON.stringify(portHead),
await clickText('+ Item'); );
await press_(portHead.add, '+ Item of the port');
await pickKind('Number'); await pickKind('Number');
await sleep(250); await sleep(250);
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`); const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
check( check(
'and the item lands in the node params, where the node reads it', 'and an item added there lands in the node params, where the node reads it',
JSON.stringify(params) === '{"list":[0]}', JSON.stringify(params) === '{"list":[0]}' && (await listInfo('Count · list'))?.rows === 1,
JSON.stringify(params), JSON.stringify(params),
); );
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'celly'); p.assets = p.assets.filter((a) => a.id !== 'celly');
@@ -472,9 +534,108 @@ export async function listTab(t) {
__gm.state.preview(); __gm.state.preview();
`); `);
await mutate('__gm.state.openList(null)'); // 21m. The Variables tab: every variable of a behavior, with the inspector's rows – the list all
// of it, where the inspector stops at 20.
const HITS = 'Varsy · hits';
// The inspector's header is the one with an Open beside it: an open Variables tab heads its rows
// with the same word, without one.
const openVariables = async () => {
const headers = await json(`__gm.locateAll('VARIABLES')`);
const opens = await json(`__gm.locateAll('Open')`);
const open = opens.find((p) => headers.some((h) => Math.abs(p.y - h.y) < 8 && p.x > h.x));
await press_(open, 'Open of VARIABLES');
await sleep(200);
};
await mutate(`
const p = __gm.state.project;
p.assets.push({
id: 'varsy',
type: 'behavior',
name: 'Varsy',
props: { enabled: true, runInEditor: false, hp: 5, hits: Array.from({ length: 25 }, (_, i) => 10 + i) },
variables: [
{ key: 'hp', label: 'hp', kind: 'number', default: 0 },
{ key: 'hits', label: 'hits', kind: 'list', default: [] },
],
graph: { nodes: [], edges: [] },
});
__gm.state.select({ kind: 'asset', assetId: 'varsy' });
`);
await sleep(150);
await openVariables();
let tab = await json('__gm.values.info()');
check(
'Open in the VARIABLES header opens a Variables tab on the behavior',
(await json('__gm.tab()')) === 'variables' &&
tab.title === 'Varsy · Variables' &&
tab.rows.join(',') === 'hp,hits',
JSON.stringify(tab),
);
check(
'where the list shows all 25 items, the inspector 20 of them',
(await listInfo(HITS, 'tab'))?.rows === 25 && (await listInfo(HITS))?.rows === 20,
JSON.stringify({ tab: await listInfo(HITS, 'tab'), inspector: await listInfo(HITS) }),
);
await shot('08-variables-tab');
await press_(await hook('fieldAt', 'tab', HITS, 0), 'hits 0 in the tab');
await typeText('99');
await press('Enter');
await sleep(150);
check(
'an item typed in the tab is in the asset and in the inspector',
(await json(`__gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[0]`)) === 99 &&
(await listInfo(HITS)).items[0] === '99',
JSON.stringify((await listInfo(HITS)).items.slice(0, 2)),
);
await undo();
// 21n. The same for a placed behavior: its tab edits the overrides of that object, not the asset.
await mutate(`
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
scene.objects.push(make('varsy-1', 'varsy', '', 0, 'Varsy One'));
__gm.state.select({ kind: 'object', holderId: scene.id, objectId: 'varsy-1', objectIds: ['varsy-1'] });
__gm.state.preview();
`);
await sleep(200);
await openVariables();
tab = await json('__gm.values.info()');
check(
'a placed behavior opens a tab of its own, named after the object',
tab.title === 'Varsy One · Variables' && (await valueDocs()).length === 2,
JSON.stringify({ tab, docs: await valueDocs() }),
);
await press_(await hook('fieldAt', 'tab', 'Varsy One · hits', 1), 'hits 1 of the object');
await typeText('77');
await press('Enter');
await sleep(150);
const placed = await json(`({
override: __gm.state.activeScene.objects.find((o) => o.id === 'varsy-1').overrides.hits?.[1] ?? null,
asset: __gm.state.project.assets.find((a) => a.id === 'varsy').props.hits[1],
})`);
check(
'an item typed there overrides the object and leaves the asset alone',
placed.override === 77 && placed.asset === 11,
JSON.stringify(placed),
);
await shot('09-object-tab');
await undo();
await closeValueDocs();
await mutate(`
const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'varsy-1');
p.assets = p.assets.filter((a) => a.id !== 'varsy');
__gm.state.select(null);
__gm.showTab('scene');
__gm.state.preview();
`);
const left = await json( const left = await json(
'__gm.state.project.assets.filter((a) => a.id === "listy" || a.id === "celly").length', '__gm.state.project.assets.filter((a) => ["listy", "celly", "varsy"].includes(a.id)).length',
);
check(
'the step leaves the fixture as it found it',
left === 0 && (await valueDocs()).length === 0,
String(left),
); );
check('the step leaves the fixture as it found it', left === 0, String(left));
} }
+18 -16
View File
@@ -120,10 +120,10 @@ export async function structVariable(t) {
JSON.stringify(await piece()), JSON.stringify(await piece()),
); );
// 22f. A field may itself be a list. Its row is the same "n items" button a list property has, // 22f. A field may itself be a list. Its items are drawn inline under it (#252), and its
// and it opens the List tab one level down – on the field, not on the whole bundle. // title names the field inside the bundle – not the whole bundle.
await clickText('+ Field'); await clickText('+ Field');
// "List" is also the tab bar's own label, so the menu entry is reached as the last drawn one. // "List" is also a word elsewhere on screen, so the menu entry is reached as the last drawn one.
const listOption = await json(`__gm.locate('List', true)`); const listOption = await json(`__gm.locate('List', true)`);
await click(listOption.x, listOption.y); await click(listOption.x, listOption.y);
check( check(
@@ -131,28 +131,32 @@ export async function structVariable(t) {
Array.isArray((await piece()).field1), Array.isArray((await piece()).field1),
JSON.stringify(await piece()), JSON.stringify(await piece()),
); );
const itemsButton = await find('0 items'); check('and that field reads as an item count', (await find('0 items')) !== null);
check('and that field reads as an item count', itemsButton !== null); const fieldList = async () =>
(await json(`__gm.values.lists('inspector')`)).find((l) => l.title.endsWith('· field1')) ?? null;
await click(itemsButton.x, itemsButton.y); const drawn = await fieldList();
check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list'); check(
const info = await json('__gm.list.info()'); 'whose items are drawn inline, titled by the field inside the bundle',
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title); drawn !== null,
JSON.stringify(drawn),
);
// Nothing bounds the depth any more (#239): a list in a struct field offers what any list does. // Nothing bounds the depth any more (#239): a list in a struct field offers what any list does.
await clickText('+ Item'); const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(drawn?.title ?? '')})`);
await click(head.add.x, head.add.y);
check( check(
'the menu of a list in a struct field offers every kind, the struct included', 'the menu of a list in a struct field offers every kind, the struct included',
(await find('Number')) !== null && (await find('Number')) !== null &&
(await find('Struct')) !== null && (await find('Struct')) !== null &&
(await json('__gm.list.info()')).offers.includes('list'), (await fieldList()).offers.includes('list'),
JSON.stringify((await json('__gm.list.info()')).offers), JSON.stringify((await fieldList()).offers),
); );
await pickKind('Number'); await pickKind('Number');
await sleep(250); await sleep(250);
check( check(
'and an item added there leaves the neighbouring fields alone', 'and an item added there leaves the neighbouring fields alone',
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}', JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}' &&
(await fieldList()).rows === 1,
JSON.stringify(await piece()), JSON.stringify(await piece()),
); );
await shot('03-list-field'); await shot('03-list-field');
@@ -164,8 +168,6 @@ export async function structVariable(t) {
JSON.stringify(await piece()), JSON.stringify(await piece()),
); );
await mutate('__gm.state.openList(null)');
// 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its // 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its
// fields one indent step further in. Both menus with real clicks. // fields one indent step further in. Both menus with real clicks.
const declaredLast = () => const declaredLast = () =>
+20 -15
View File
@@ -104,8 +104,9 @@ export async function scope(t) {
); );
await stop(); await stop();
// 32b-ii. A list the run fills: the inspector row counts the run's items, so the List tab it // 32b-ii. A list the run fills: the inspector row counts the run's items, so the items drawn
// opens has to show those too and follow them – not the empty list the document declares. // under it and in the Variables tab it opens have to be those too, and follow them – not
// the empty list the document declares.
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
const scene = __gm.state.activeScene; const scene = __gm.state.activeScene;
@@ -128,6 +129,8 @@ export async function scope(t) {
${MAKE_OBJECT} ${MAKE_OBJECT}
scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler')); scene.objects.push(make('sc-brain-f', 'sc-filler', 'sc-box-a', 0, 'Filler'));
`); `);
const bagIn = async (where) =>
(await json(`__gm.values.lists(${JSON.stringify(where)})`)).find((l) => l.title === 'Bag · bag') ?? null;
// Held, so the count the row shows stands still long enough to be found and clicked. // Held, so the count the row shows stands still long enough to be found and clicked.
await t.play(); await t.play();
await sleep(300); await sleep(300);
@@ -137,37 +140,39 @@ export async function scope(t) {
await selectAsset('sc-bag'); await selectAsset('sc-bag');
await sleep(200); await sleep(200);
const countRow = await t.find(`${held.length} items`); const countRow = await t.find(`${held.length} items`);
if (countRow) await click(countRow.x, countRow.y); const inline = await bagIn('inspector');
const head = await json(`__gm.values.headAt('inspector', 'Bag · bag')`);
if (head?.open) await click(head.open.x, head.open.y);
await sleep(150); await sleep(150);
const first = await json('__gm.list.info()'); const first = await bagIn('tab');
await shot('01b-live-list'); await shot('01b-live-list');
await t.clickToolbar('pause'); await t.clickToolbar('pause');
await sleep(500); await sleep(500);
const later = await json('__gm.list.info()'); const later = await bagIn('tab');
await stop(); await stop();
await sleep(200); await sleep(200);
const stopped = await json('__gm.list.info()'); const stopped = await bagIn('tab');
check( check(
'while playing, the List tab opened from a shared list shows the items the run put in', 'while playing, a shared list shows the items the run put in – inline and in its tab',
held.length > 0 && held.length > 0 &&
Boolean(countRow) && Boolean(countRow) &&
(await json('__gm.tab()')) === 'list' && inline?.rows === Math.min(held.length, 20) &&
first.title === 'Bag · bag' && (await json('__gm.tab()')) === 'variables' &&
first.rows === held.length && first?.rows === held.length &&
first.items.every((item) => item === '7'), first.items.every((item) => item === '7'),
JSON.stringify({ run: held.length, row: countRow, title: first.title, rows: first.rows }), JSON.stringify({ run: held.length, row: countRow, inline: inline?.rows, tab: first?.rows }),
); );
check( check(
'and follows the list as it grows, then shows the declared list again once the run stops', 'and the tab follows the list as it grows, then shows the declared list again once the run stops',
later.rows > first.rows && stopped.rows === 0 && stopped.items.length === 0, later?.rows > first?.rows && stopped?.rows === 0 && stopped.items.length === 0,
JSON.stringify({ first: first.rows, later: later.rows, stopped: stopped.rows }), JSON.stringify({ first: first?.rows, later: later?.rows, stopped: stopped?.rows }),
); );
await mutate(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f'); __gm.state.activeScene.objects = __gm.state.activeScene.objects.filter((o) => o.id !== 'sc-brain-f');
p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag'); p.assets = p.assets.filter((a) => a.id !== 'sc-filler' && a.id !== 'sc-bag');
__gm.state.openList(null);
__gm.state.select(null); __gm.state.select(null);
__gm.state.preview();
`); `);
// 32c. The asset is where the declaration lives, and it says what it is for. // 32c. The asset is where the declaration lives, and it says what it is for.
+3 -3
View File
@@ -170,12 +170,12 @@ const DOCUMENT_KINDS = [
'curve', 'curve',
'tile', 'tile',
'container', 'container',
'list', 'variables',
'management', 'management',
]; ];
/** /**
* Clicks the tab of an open document (`asset:<id>` or `list`), through the `»` menu when the row * Clicks the tab of an open document (`asset:<id>` or `values:<key>`), through the `»` menu when the row
* hides it. The press lands left of the ×, on the name. * hides it. The press lands left of the ×, on the name.
*/ */
export async function clickDocumentTab(t, doc) { export async function clickDocumentTab(t, doc) {
@@ -188,7 +188,7 @@ export async function clickDocumentTab(t, doc) {
if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`); if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`);
const label = await t.json(`(() => { const label = await t.json(`(() => {
const id = ${JSON.stringify(doc)}; const id = ${JSON.stringify(doc)};
if (id === 'list') return null; if (id.startsWith('values:')) return __gm.tabRow().labels?.[id] ?? null;
const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id); const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id);
return asset ? asset.name : null; return asset ? asset.name : null;
})()`); })()`);