Show lists in the inspector and variables in their own tab (#252)
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:
1 parent
92fad6b9e3
commit
4857bb75e6
26 files changed
+1203
-427
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
|
|||||||
@@ -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,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
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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*.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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']] } });
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
})()`);
|
})()`);
|
||||||
|
|||||||
Reference in new issue
Block a user