Give a list one struct type so every item keeps its shape (#254)
A list of structs used to let every item carry its own fields. Adding an item meant typing the same fields again, and nothing kept the items in step with the struct asset from #253. A list can now name a struct type for its items, and the asset keeps them in shape. - A list variable and a list field get the row Items with Mixed, every struct asset and Make struct type. Make struct type collects every field any item has, so no item loses a field. - A typed item carries its type under the key `$struct` (ITEM_TYPE_KEY). listItems does not count it as a field. - A list that holds anything but structs refuses a type, also when the offending item sits in an override. The console names the list and the count, and the list stays as it was. - In a typed list `+ Item` adds an item with the defaults, without the kind menu. Field names are locked, values stay editable. - The rows of a typed item take their kind from the struct asset (declaredKind in InlineList.ts). An asset field shows the asset picker instead of a text field, as reported in the issue. - A struct item in a mixed list gets its own Type row. Switching a list back to Mixed keeps the type on each item. - syncStructTypes brings marked items into shape and carries renames into them, also inside overrides and nested lists. One undo step. - The delete dialog of a struct asset also counts typed lists. - The game never sees the key. World and sharedState strip it with withoutItemTypes and bagWithoutItemTypes, so a struct stays a plain object at runtime. - Add item and Set item get the setting Type. With a type they only accept a struct with exactly its field names (fitsType in SetItem.ts). Anything else is left out and logged. - The item indent from #252 is back to its old width, the narrow one squeezed the value fields. Not done: writing a variable does not check the type, and the List node offers no type. Found on the way: a test helper in list-of-structs.test.ts called nodes without params. It now passes the same arguments as the interpreter. Tests: - ./check.sh: 183 files, 2679 tests, CHECK EXIT 0 - e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks - e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks - New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks - Counter-checks bite in unit and browser, originals restored by cmp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
61bbb96a96
commit
b0d0868f55
25 files changed
+1446
-86
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:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1500 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1513 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -7,17 +7,29 @@ import type { GraphNode } from '../graph/Graph';
|
||||
import { STRUCT_TYPE_PARAM } from '../graph/nested';
|
||||
import type { StructAsset } from '../model/Asset';
|
||||
import { nextAssetName } from './naming';
|
||||
import { isStruct, type PropertyValue } from '../model/Properties';
|
||||
import {
|
||||
isList,
|
||||
isStruct,
|
||||
itemTypeOf,
|
||||
type ListValue,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
tagOf,
|
||||
} from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
containsType,
|
||||
type FieldRename,
|
||||
isTypedNode,
|
||||
listDeclAt,
|
||||
setListItemType,
|
||||
setStructType,
|
||||
structAssetOf,
|
||||
structAt,
|
||||
syncStructTypes,
|
||||
variableValues,
|
||||
} from '../model/structTypes';
|
||||
import { structItemFields } from '../properties/listItems';
|
||||
import {
|
||||
coerceValue,
|
||||
type PropertyDef,
|
||||
@@ -127,6 +139,7 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined
|
||||
);
|
||||
if (value !== undefined) def.default = coerceValue(def, value);
|
||||
if (def.kind === 'struct' && field.kind === 'struct' && field.structType) def.structType = field.structType;
|
||||
if (def.kind === 'list' && field.kind === 'list' && field.itemType) def.itemType = field.itemType;
|
||||
return def;
|
||||
}
|
||||
|
||||
@@ -176,3 +189,134 @@ export class MakeStructTypeCommand implements Command {
|
||||
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** A list variable, or a list field inside a struct variable – `path` leads to the field (#254). */
|
||||
export interface ListTarget {
|
||||
ownerId: string;
|
||||
key: string;
|
||||
path: readonly string[];
|
||||
}
|
||||
|
||||
function requireListDecl(project: Project, target: ListTarget) {
|
||||
const owner = requireVariableOwner(project, target.ownerId);
|
||||
const def = owner.variables.find((v) => v.key === target.key);
|
||||
const decl = def && listDeclAt(def, target.path);
|
||||
if (!def || !decl) throw new CommandError(`"${[target.key, ...target.path].join('.')}" is no list`);
|
||||
return { owner, def, decl };
|
||||
}
|
||||
|
||||
/** Every list the target holds anywhere – its default, the owner's value, each override. */
|
||||
function listsOf(project: Project, target: ListTarget): ListValue[] {
|
||||
const { owner, def } = requireListDecl(project, target);
|
||||
return variableValues(project, owner, def).flatMap((value) => {
|
||||
const list = valueAt(value, target.path);
|
||||
return isList(list) ? [list] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const isStructItem = (item: PropertyValue): boolean => isStruct(item) && tagOf(item) === undefined;
|
||||
|
||||
/**
|
||||
* A list holding anything but structs cannot take a type: which of its items would become what is
|
||||
* not for the editor to guess (#254). The refusal says so, in the console like any refused edit.
|
||||
*/
|
||||
function requireStructItems(project: Project, target: ListTarget): ListValue[] {
|
||||
const lists = listsOf(project, target);
|
||||
const others = lists.reduce((n, list) => n + list.filter((item) => !isStructItem(item)).length, 0);
|
||||
if (others > 0) {
|
||||
const name = [target.key, ...target.path].join('.');
|
||||
const what = others === 1 ? '1 item that is' : `${others} items that are`;
|
||||
throw new CommandError(`${name} holds ${what} no struct – only a list of structs can take a type`);
|
||||
}
|
||||
return lists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes every item of a list one Struct asset, or with `''` a mixed list again. Items keep their
|
||||
* values where the type has the field; a mixed list whose items keep their marks loses nothing.
|
||||
*/
|
||||
export class SetListItemTypeCommand implements Command {
|
||||
readonly label: string;
|
||||
private rec = new Recorder();
|
||||
|
||||
constructor(
|
||||
private readonly target: ListTarget,
|
||||
private readonly typeId: string,
|
||||
) {
|
||||
this.label = typeId ? 'Set item type' : 'Mixed items';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (this.typeId && !structAssetOf(project, this.typeId)) throw new CommandError('That struct is gone');
|
||||
const { def } = requireListDecl(project, this.target);
|
||||
if (this.typeId) requireStructItems(project, this.target);
|
||||
this.rec = new Recorder();
|
||||
setListItemType(def, this.target.path, this.typeId, this.rec);
|
||||
syncStructTypes(project, this.rec);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.rec.revert();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the shape of a list's items into a Struct asset and types the list with it. Every field any
|
||||
* item has is in it, at the value the first item holding it has – so no item loses a field.
|
||||
*/
|
||||
export class MakeListItemTypeCommand implements Command {
|
||||
readonly label = 'Make struct type';
|
||||
private asset?: StructAsset;
|
||||
private rec = new Recorder();
|
||||
|
||||
constructor(private readonly target: ListTarget) {}
|
||||
|
||||
get assetId(): string {
|
||||
return this.asset?.id ?? '';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const { def, decl } = requireListDecl(project, this.target);
|
||||
if (decl.itemType && structAssetOf(project, decl.itemType))
|
||||
throw new CommandError('This list has a type already');
|
||||
const lists = requireStructItems(project, this.target);
|
||||
if (!this.asset) {
|
||||
const items = lists.flat().filter(isStruct);
|
||||
const values: Record<string, PropertyValue> = {};
|
||||
const fields: StructFieldDef[] = [];
|
||||
for (const item of items)
|
||||
for (const field of itemFieldDefs(item))
|
||||
if (!(field.key in values)) {
|
||||
values[field.key] = item[field.key]!;
|
||||
fields.push(field);
|
||||
}
|
||||
const variables = fields.map((f) => variableOfField(f, values[f.key]));
|
||||
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
|
||||
const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key);
|
||||
this.asset = { id: newId(), type: 'struct', name, props, variables };
|
||||
}
|
||||
project.assets.push(this.asset);
|
||||
this.rec = new Recorder();
|
||||
setListItemType(def, this.target.path, this.asset.id, this.rec);
|
||||
syncStructTypes(project, this.rec);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rec.revert();
|
||||
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The fields a struct item has, as a declaration: a struct in it declares its own again. */
|
||||
function itemFieldDefs(item: StructValue): StructFieldDef[] {
|
||||
return structItemFields(item).map(({ key, kind }): StructFieldDef => {
|
||||
const value = item[key];
|
||||
if (kind === 'struct' && isStruct(value)) {
|
||||
const typeId = itemTypeOf(value);
|
||||
return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) };
|
||||
}
|
||||
if (kind === 'struct') return { key, kind, fields: [] };
|
||||
if (kind === 'list') return { key, kind };
|
||||
return { key, kind };
|
||||
});
|
||||
}
|
||||
@@ -37,13 +37,25 @@ export interface StateMachine {
|
||||
|
||||
/**
|
||||
* What a node may look up in the project around its graph: the behaviors it can run, the state
|
||||
* machines it can read and the curves a `Lerp` eases along (#247). One handle, so no caller can
|
||||
* resolve a node with half of it.
|
||||
* machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item`
|
||||
* checks (#254). One handle, so no caller can resolve a node with half of it.
|
||||
*/
|
||||
export interface GraphLookup {
|
||||
behavior(assetId: string): BehaviorProgram | null;
|
||||
machine(assetId: string): StateMachine | null;
|
||||
curve(assetId: string): readonly VectorPoint[] | null;
|
||||
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
|
||||
structType(assetId: string): StructShape | null;
|
||||
}
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null };
|
||||
export interface StructShape {
|
||||
name: string;
|
||||
fields: readonly string[];
|
||||
}
|
||||
|
||||
export const NO_LOOKUP: GraphLookup = {
|
||||
behavior: () => null,
|
||||
machine: () => null,
|
||||
curve: () => null,
|
||||
structType: () => null,
|
||||
};
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { PropertyValue } from '../../../model/Properties';
|
||||
import { type PropertyValue, textOf } from '../../../model/Properties';
|
||||
import { itemKindOf } from '../../../properties/listItems';
|
||||
import { STRUCT_TYPE_SETTING } from '../../nested';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber, asValueItem, type ValueList } from '../../values';
|
||||
import { asList, asNumber, asString, asValueItem, type ValueList } from '../../values';
|
||||
import { any, int, list } from '../shared';
|
||||
import { fitToSlot } from './SetItem';
|
||||
import { fitsType, fitToSlot } from './SetItem';
|
||||
|
||||
export const AddItem: NodeDef = {
|
||||
type: 'AddItem',
|
||||
label: 'Add item',
|
||||
category: 'data',
|
||||
description:
|
||||
'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone.',
|
||||
'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.',
|
||||
inputs: [list('list'), any('value'), int('index', -1)],
|
||||
outputs: [list('result')],
|
||||
run: (_ctx, inputs) => {
|
||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const items = asList(inputs.list);
|
||||
const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? '');
|
||||
const type = typeId ? lookup.structType(typeId) : null;
|
||||
if (type && !fitsType(inputs.value, type)) {
|
||||
ctx.log(`Add item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not added`);
|
||||
return { outputs: { result: items } };
|
||||
}
|
||||
const i = inputs.index === undefined ? -1 : Math.floor(asNumber(inputs.index));
|
||||
// No index, −1 or anything outside the list appends – an index can only ever miss towards the end.
|
||||
const at = i >= 0 && i <= items.length ? i : items.length;
|
||||
@@ -22,7 +29,7 @@ export const AddItem: NodeDef = {
|
||||
next.splice(at, 0, fitted(items, asValueItem(inputs.value)));
|
||||
return { outputs: { result: next } };
|
||||
},
|
||||
params: [],
|
||||
params: [STRUCT_TYPE_SETTING],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
import type { PropertyValue } from '../../../model/Properties';
|
||||
import { isStruct, type PropertyValue, tagOf, textOf } from '../../../model/Properties';
|
||||
import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems';
|
||||
import type { StructShape } from '../../lookup';
|
||||
import { STRUCT_TYPE_SETTING } from '../../nested';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber, asValueItem, isEntity, type ValueList } from '../../values';
|
||||
import { asList, asNumber, asString, asValueItem, isEntity, type Value, type ValueList } from '../../values';
|
||||
import { any, int, list } from '../shared';
|
||||
|
||||
export const SetItem: NodeDef = {
|
||||
type: 'SetItem',
|
||||
label: 'Set item',
|
||||
category: 'data',
|
||||
description: 'The same list with one item replaced. The list that came in is left alone.',
|
||||
description:
|
||||
'The same list with one item replaced. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.',
|
||||
inputs: [list('list'), int('index'), any('value')],
|
||||
outputs: [list('result')],
|
||||
run: (_ctx, inputs) => {
|
||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const items = asList(inputs.list);
|
||||
const i = Math.floor(asNumber(inputs.index));
|
||||
if (i < 0 || i >= items.length) return { outputs: { result: items } };
|
||||
// Checked like `Add item` (#254): by the field names, the only thing a run knows a struct by.
|
||||
const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? '');
|
||||
const type = typeId ? lookup.structType(typeId) : null;
|
||||
if (type && !fitsType(inputs.value, type)) {
|
||||
ctx.log(`Set item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not set`);
|
||||
return { outputs: { result: items } };
|
||||
}
|
||||
const next = [...items];
|
||||
// The slot keeps its kind: writing a number into a color item leaves a color behind, the same
|
||||
// way writing into a number variable leaves a number (see `asTypeOf`).
|
||||
next[i] = fitToSlot(items[i], asValueItem(inputs.value));
|
||||
return { outputs: { result: next } };
|
||||
},
|
||||
params: [],
|
||||
params: [STRUCT_TYPE_SETTING],
|
||||
};
|
||||
|
||||
/**
|
||||
* A run knows a struct only by its fields (#254): one fits a type when it has exactly the type's
|
||||
* field names – none missing, none more.
|
||||
*/
|
||||
export function fitsType(value: Value | undefined, type: StructShape): boolean {
|
||||
if (isEntity(value) || !isStruct(value) || tagOf(value) !== undefined) return false;
|
||||
const keys = Object.keys(value);
|
||||
return keys.length === type.fields.length && type.fields.every((field) => field in value);
|
||||
}
|
||||
|
||||
/** `value` as the kind `slot` holds, the way `Set item` and `Add item` write into a list. */
|
||||
export function fitToSlot(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] {
|
||||
// An object has no kind to keep and none to take on, so it is written as it is (#157).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { type PropertyBag, withoutItemTypes } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
|
||||
import type { NodeContext, SharedState } from './NodeContext';
|
||||
@@ -14,7 +14,7 @@ export function declaredVariables(project: Project): Record<string, PropertyBag>
|
||||
for (const asset of project.assets) {
|
||||
if (asset.type !== 'variables') continue;
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default;
|
||||
for (const def of asset.variables) bag[def.key] = withoutItemTypes(asset.props[def.key] ?? def.default);
|
||||
declared[asset.id] = bag;
|
||||
}
|
||||
return declared;
|
||||
|
||||
@@ -32,12 +32,55 @@ interface TaggedFields {
|
||||
|
||||
const TAG = '$';
|
||||
|
||||
/**
|
||||
* The Struct asset a struct item follows (#254), kept in the item because a list declares at most
|
||||
* one type for all of them – and a mixed list none. Model only: a run never sees it
|
||||
* (`withoutItemTypes`), and no field can be called that, as no field name starts with `$`.
|
||||
*/
|
||||
export const ITEM_TYPE_KEY = '$struct';
|
||||
|
||||
/** The kind `value` wears as a tag, if it is a tagged item. */
|
||||
export function tagOf(value: unknown): string | undefined {
|
||||
if (!isStruct(value)) return undefined;
|
||||
const keys = Object.keys(value);
|
||||
const key = keys.length === 1 ? keys[0] : undefined;
|
||||
return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined;
|
||||
return key?.startsWith(TAG) && key !== ITEM_TYPE_KEY ? key.slice(TAG.length) : undefined;
|
||||
}
|
||||
|
||||
/** The Struct asset id a struct item is marked with, if any. */
|
||||
export function itemTypeOf(value: PropertyValue | undefined): string | undefined {
|
||||
if (!isStruct(value)) return undefined;
|
||||
const id = value[ITEM_TYPE_KEY];
|
||||
return typeof id === 'string' && id !== '' ? id : undefined;
|
||||
}
|
||||
|
||||
/** The struct with its fields as they are, marked as `typeId` – or unmarked for `''`. */
|
||||
export function withItemType(value: StructValue, typeId: string): StructValue {
|
||||
const { [ITEM_TYPE_KEY]: _dropped, ...fields } = value;
|
||||
return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields;
|
||||
}
|
||||
|
||||
/** The value as a run holds it: every item-type mark taken out, at any depth. */
|
||||
export function withoutItemTypes<T extends PropertyValue | undefined>(value: T): T {
|
||||
if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T;
|
||||
if (!isStruct(value) || !holdsItemType(value)) return value;
|
||||
const out: Record<string, PropertyValue> = {};
|
||||
for (const [key, item] of Object.entries(value))
|
||||
if (key !== ITEM_TYPE_KEY) out[key] = withoutItemTypes(item);
|
||||
return out as T;
|
||||
}
|
||||
|
||||
export function bagWithoutItemTypes(bag: PropertyBag): PropertyBag {
|
||||
if (!Object.values(bag).some(holdsItemType)) return bag;
|
||||
const out: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(bag)) out[key] = withoutItemTypes(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
function holdsItemType(value: PropertyValue | undefined): boolean {
|
||||
if (isList(value)) return value.some(holdsItemType);
|
||||
if (!isStruct(value)) return false;
|
||||
return ITEM_TYPE_KEY in value || Object.values(value).some(holdsItemType);
|
||||
}
|
||||
|
||||
export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
|
||||
@@ -75,6 +118,7 @@ export function textOf(value: PropertyValue | undefined): string {
|
||||
if (tagOf(value) !== undefined) return textOf(untag(value));
|
||||
if (isStruct(value))
|
||||
return Object.entries(value)
|
||||
.filter(([field]) => field !== ITEM_TYPE_KEY)
|
||||
.map(([field, item]) => `${field}: ${textOf(item)}`)
|
||||
.join(', ');
|
||||
return String(value);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { bagWithoutItemTypes } from './Properties';
|
||||
import { typeFields } from './structTypes';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
/**
|
||||
@@ -15,7 +17,7 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
return {
|
||||
name: asset.name,
|
||||
graph: asset.graph,
|
||||
defaults: resolveAssetProps(project, asset),
|
||||
defaults: bagWithoutItemTypes(resolveAssetProps(project, asset)),
|
||||
kept: asset.keptVariables ?? [],
|
||||
};
|
||||
},
|
||||
@@ -30,5 +32,9 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
};
|
||||
},
|
||||
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
|
||||
structType: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'struct');
|
||||
return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
+208
-10
@@ -3,6 +3,9 @@
|
||||
* of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such
|
||||
* copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset
|
||||
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
|
||||
*
|
||||
* Items declare nothing, so a struct item carries its type as a mark (#254): every item of a typed
|
||||
* list, and any struct item given one in a mixed list. The same sync keeps marked items in shape.
|
||||
*/
|
||||
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
|
||||
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
|
||||
@@ -16,11 +19,22 @@ import {
|
||||
type StructFieldDef,
|
||||
structOf,
|
||||
} from '../properties/PropertySchema';
|
||||
import { valueAt, withValueAt } from '../properties/valuePath';
|
||||
import type { Recorder } from '../util/recorder';
|
||||
import { valueAt, type ValuePath, withValueAt } from '../properties/valuePath';
|
||||
import { Recorder } from '../util/recorder';
|
||||
import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties';
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
ITEM_TYPE_KEY,
|
||||
itemTypeOf,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
tagOf,
|
||||
textOf,
|
||||
withItemType,
|
||||
} from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
export const PACK_STRUCT = 'PackStruct';
|
||||
@@ -50,7 +64,8 @@ export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
|
||||
fields: def.fields,
|
||||
...(def.structType ? { structType: def.structType } : {}),
|
||||
};
|
||||
if (def.kind === 'list') return { key: def.key, kind: 'list' };
|
||||
if (def.kind === 'list')
|
||||
return { key: def.key, kind: 'list', ...(def.itemType ? { itemType: def.itemType } : {}) };
|
||||
return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined;
|
||||
}
|
||||
|
||||
@@ -144,11 +159,12 @@ interface Holder {
|
||||
write(value: PropertyValue): void;
|
||||
}
|
||||
|
||||
function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] {
|
||||
const { owner, def } = site;
|
||||
const holders: Holder[] = [
|
||||
{ read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) },
|
||||
];
|
||||
function holdersOf(
|
||||
project: Project,
|
||||
{ owner, def }: { owner: VariableOwnerAsset; def: PropertyDef },
|
||||
rec: Recorder,
|
||||
): Holder[] {
|
||||
const holders: Holder[] = [{ read: () => def.default, write: (value) => rec.set(def, 'default', value) }];
|
||||
if (owner.props[def.key] !== undefined)
|
||||
holders.push({
|
||||
read: () => owner.props[def.key],
|
||||
@@ -183,10 +199,97 @@ export function shapedStruct(
|
||||
} else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field);
|
||||
else
|
||||
shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field);
|
||||
if (field.kind === 'list' && field.itemType)
|
||||
shaped[field.key] = markedItems(shaped[field.key], field.itemType);
|
||||
}
|
||||
return shaped;
|
||||
}
|
||||
|
||||
/** A new item of the type (#254): the type's defaults, or what `value` has of its fields, marked. */
|
||||
export function typedItem(project: Project, type: StructAsset, value?: PropertyValue): StructValue {
|
||||
return withItemType(shapedStruct(project, typeFields(type), value, typeDefault(type)), type.id);
|
||||
}
|
||||
|
||||
/** The struct items of a typed list marked with its type; anything else is left as it is. */
|
||||
function markedItems(value: PropertyValue | undefined, typeId: string): PropertyValue {
|
||||
if (!isList(value)) return [];
|
||||
return value.map((item) =>
|
||||
isStruct(item) && tagOf(item) === undefined && itemTypeOf(item) !== typeId
|
||||
? withItemType(item, typeId)
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
/** A declared value with the items of every typed list in it marked – the declaration says which. */
|
||||
function declaredMarks(
|
||||
def: PropertyDef | StructFieldDef,
|
||||
value: PropertyValue | undefined,
|
||||
): PropertyValue | undefined {
|
||||
if (def.kind === 'list') return def.itemType ? markedItems(value, def.itemType) : value;
|
||||
if (def.kind !== 'struct' || !isStruct(value)) return value;
|
||||
const out: Record<string, PropertyValue> = { ...value };
|
||||
for (const field of def.fields) {
|
||||
const next = declaredMarks(field, value[field.key]);
|
||||
if (next !== undefined) out[field.key] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every marked struct item inside `value` brought into the shape of its type, at any depth – what
|
||||
* a typed struct variable gets from `shapedStruct`, for the items no declaration holds. A mark
|
||||
* naming a type that is gone leaves the item as it is.
|
||||
*/
|
||||
function shapedItems(project: Project, value: PropertyValue, depth = 0): PropertyValue {
|
||||
if (depth > MAX_DEPTH) return value;
|
||||
if (isList(value)) return value.map((item) => shapedItems(project, item, depth + 1));
|
||||
if (!isStruct(value) || tagOf(value) !== undefined) return value;
|
||||
const type = structAssetOf(project, itemTypeOf(value));
|
||||
const shaped = type ? typedItem(project, type, value) : value;
|
||||
const out: Record<string, PropertyValue> = {};
|
||||
for (const [key, item] of Object.entries(shaped))
|
||||
out[key] = key === ITEM_TYPE_KEY ? item : shapedItems(project, item, depth + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A rename reaching the marked items: each one of a type holding the renamed field, at any depth. */
|
||||
function renamedItems(project: Project, value: PropertyValue, rename: FieldRename, depth = 0): PropertyValue {
|
||||
if (depth > MAX_DEPTH) return value;
|
||||
if (isList(value)) return value.map((item) => renamedItems(project, item, rename, depth + 1));
|
||||
if (!isStruct(value) || tagOf(value) !== undefined) return value;
|
||||
let next: PropertyValue = value;
|
||||
const typeId = itemTypeOf(value);
|
||||
if (typeId)
|
||||
for (const inside of typePaths(project, typeId, rename.structType)) {
|
||||
const path = [...inside, ...rename.path];
|
||||
const at = valueAt(next, path);
|
||||
if (isStruct(at) && rename.from in at)
|
||||
next = withValueAt(next, path, renamedKey(at, rename.from, rename.to)) ?? next;
|
||||
}
|
||||
const out: Record<string, PropertyValue> = {};
|
||||
for (const [key, item] of Object.entries(next as StructValue))
|
||||
out[key] = key === ITEM_TYPE_KEY ? item : renamedItems(project, item, rename, depth + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every variable of every asset declaring some, with where its value is kept. */
|
||||
function everyVariable(project: Project): { owner: VariableOwnerAsset; def: PropertyDef }[] {
|
||||
return project.assets.flatMap((owner) =>
|
||||
isVariableOwner(owner) ? owner.variables.map((def) => ({ owner, def })) : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Every value kept for a variable – what an edit of its declaration has to fit. */
|
||||
export function variableValues(
|
||||
project: Project,
|
||||
owner: VariableOwnerAsset,
|
||||
def: PropertyDef,
|
||||
): PropertyValue[] {
|
||||
return holdersOf(project, { owner, def }, new Recorder())
|
||||
.map((holder) => holder.read())
|
||||
.filter((value) => value !== undefined);
|
||||
}
|
||||
|
||||
/** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */
|
||||
function withFieldsAt(
|
||||
fields: readonly StructFieldDef[],
|
||||
@@ -275,6 +378,11 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
|
||||
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
|
||||
}
|
||||
}
|
||||
for (const variable of everyVariable(project))
|
||||
for (const holder of holdersOf(project, variable, rec)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) holder.write(renamedItems(project, value, rename));
|
||||
}
|
||||
if (rename.path.length > 0) return;
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
if (nodeStructType(node) !== rename.structType) continue;
|
||||
@@ -357,6 +465,11 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
|
||||
holder.write(withValueAt(value, site.path, next) ?? next);
|
||||
}
|
||||
}
|
||||
for (const variable of everyVariable(project))
|
||||
for (const holder of holdersOf(project, variable, rec)) {
|
||||
const value = declaredMarks(variable.def, holder.read());
|
||||
if (value !== undefined) holder.write(shapedItems(project, value));
|
||||
}
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
const type = structAssetOf(project, nodeStructType(node));
|
||||
if (type) syncNodePorts(graph, node, typeFields(type), rec);
|
||||
@@ -366,9 +479,94 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
|
||||
}
|
||||
|
||||
/** How many places name the type – what deleting it would cut loose. */
|
||||
export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } {
|
||||
export function structTypeUsages(
|
||||
project: Project,
|
||||
typeId: string,
|
||||
): { places: number; lists: number; nodes: number } {
|
||||
const lists = everyVariable(project).flatMap(({ def }) => typedListsIn(def));
|
||||
return {
|
||||
places: typedSites(project).filter((site) => site.structType === typeId).length,
|
||||
lists: lists.filter((id) => id === typeId).length,
|
||||
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The item types of the typed lists a declaration holds; a typed struct's are its type's. */
|
||||
function typedListsIn(def: PropertyDef | StructFieldDef): string[] {
|
||||
if (def.kind === 'list') return def.itemType ? [def.itemType] : [];
|
||||
if (def.kind !== 'struct' || def.structType) return [];
|
||||
return def.fields.flatMap(typedListsIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The declaration of the list at `path` inside a variable: the variable itself for none, else the
|
||||
* list field the struct keys lead to. Undefined where there is no declared list.
|
||||
*/
|
||||
export function listDeclAt(def: PropertyDef, path: readonly string[]): { itemType?: string } | undefined {
|
||||
if (path.length === 0) return def.kind === 'list' ? def : undefined;
|
||||
if (def.kind !== 'struct') return undefined;
|
||||
const at = structAt(def, path.slice(0, -1));
|
||||
const field = at?.fields.find((f) => f.key === path.at(-1));
|
||||
return field?.kind === 'list' ? field : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The item type of the list at `path` inside a variable's value, wherever the path runs: through
|
||||
* declared struct fields, list items and marked struct items alike. A list inside a typed item has
|
||||
* the item type its type declares for that field.
|
||||
*/
|
||||
export function listItemTypeAt(
|
||||
project: Project,
|
||||
def: PropertyDef,
|
||||
value: PropertyValue | undefined,
|
||||
path: ValuePath,
|
||||
): string | undefined {
|
||||
let decl: { fields?: readonly StructFieldDef[]; itemType?: string } =
|
||||
def.kind === 'struct' ? { fields: def.fields } : def.kind === 'list' ? { itemType: def.itemType } : {};
|
||||
let at = value;
|
||||
for (const step of path) {
|
||||
at = valueAt(at, [step]);
|
||||
const field = typeof step === 'string' ? decl.fields?.find((f) => f.key === step) : undefined;
|
||||
if (field?.kind === 'list') decl = { itemType: field.itemType };
|
||||
else if (field?.kind === 'struct') decl = { fields: field.fields };
|
||||
else {
|
||||
const type = structAssetOf(
|
||||
project,
|
||||
(typeof step === 'number' ? decl.itemType : undefined) ?? itemTypeOf(at),
|
||||
);
|
||||
decl = type ? { fields: typeFields(type) } : {};
|
||||
}
|
||||
}
|
||||
return decl.itemType;
|
||||
}
|
||||
|
||||
/** Names the item type of the list at `path` (see `listDeclAt`); `''` makes it mixed again. */
|
||||
export function setListItemType(
|
||||
def: PropertyDef,
|
||||
path: readonly string[],
|
||||
typeId: string,
|
||||
rec: Recorder,
|
||||
): void {
|
||||
if (path.length === 0) {
|
||||
if (def.kind !== 'list') return;
|
||||
if (typeId) rec.set(def, 'itemType', typeId);
|
||||
else rec.delete(def, 'itemType');
|
||||
return;
|
||||
}
|
||||
if (def.kind !== 'struct') return;
|
||||
const last = path.at(-1);
|
||||
const retyped = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
|
||||
fields.map((f) => {
|
||||
if (f.key !== last || f.kind !== 'list') return f;
|
||||
const { itemType: _dropped, ...rest } = f;
|
||||
return typeId ? { ...rest, itemType: typeId } : rest;
|
||||
});
|
||||
const parent = path.slice(0, -1);
|
||||
rec.set(
|
||||
def,
|
||||
'fields',
|
||||
parent.length === 0
|
||||
? retyped(def.fields)
|
||||
: withFieldsAt(def.fields, parent, (f) => ({ ...f, fields: retyped(f.fields) })),
|
||||
);
|
||||
}
|
||||
@@ -163,9 +163,10 @@ export type PropertyDef =
|
||||
| FlatDef
|
||||
/**
|
||||
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
|
||||
* and the graph types a list as a list, so one `list` port fits them all.
|
||||
* and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes
|
||||
* every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`).
|
||||
*/
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string }
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
* keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
|
||||
@@ -196,10 +197,14 @@ export interface PrimitiveFieldDef {
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
/** A list held by a struct field. Its items declare nothing either, and may nest like any list. */
|
||||
/**
|
||||
* A list held by a struct field. Its items declare nothing either, and may nest like any list –
|
||||
* unless `itemType` names the Struct asset every item is (#254), as on a list variable.
|
||||
*/
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
itemType?: string;
|
||||
}
|
||||
|
||||
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* through the same coercion again, so nothing deeper is dropped.
|
||||
*/
|
||||
import {
|
||||
ITEM_TYPE_KEY,
|
||||
isList,
|
||||
isStruct,
|
||||
type ListValue,
|
||||
@@ -65,5 +66,7 @@ export function structItemOf(value: PropertyValue | undefined): StructValue {
|
||||
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
||||
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: NestedKind }[] {
|
||||
if (!isStruct(item) || tagOf(item) !== undefined) return [];
|
||||
return Object.entries(item).map(([key, value]) => ({ key, kind: itemKindOf(value) }));
|
||||
return Object.entries(item)
|
||||
.filter(([key]) => key !== ITEM_TYPE_KEY)
|
||||
.map(([key, value]) => ({ key, kind: itemKindOf(value) }));
|
||||
}
|
||||
@@ -393,6 +393,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const def = variableDef(key, kind, undefined, fields);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
if (def.kind === 'struct') readStructType(def, v.structType);
|
||||
if (def.kind === 'list') readItemType(def, v.itemType);
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
@@ -416,7 +417,11 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
|
||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||
if (f.kind === 'list') return { key, kind: 'list' };
|
||||
if (f.kind === 'list') {
|
||||
const field: StructFieldDef = { key, kind: 'list' };
|
||||
readItemType(field, f.itemType);
|
||||
return field;
|
||||
}
|
||||
if (f.kind === 'struct') {
|
||||
const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
|
||||
readStructType(field, f.structType);
|
||||
@@ -430,6 +435,11 @@ function readStructType(def: { structType?: string }, raw: unknown): void {
|
||||
if (typeof raw === 'string' && raw !== '') def.structType = raw;
|
||||
}
|
||||
|
||||
/** The same for a typed list (#254): gone, it is a mixed list whose items keep their marks. */
|
||||
function readItemType(def: { itemType?: string }, raw: unknown): void {
|
||||
if (typeof raw === 'string' && raw !== '') def.itemType = raw;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set(fixed);
|
||||
|
||||
@@ -134,14 +134,16 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const animations = picture ? animationsUsing(state.project, ids).length : 0;
|
||||
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
|
||||
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
|
||||
const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, nodes: 0 };
|
||||
const typed =
|
||||
asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 };
|
||||
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: eased.keys > 0 || eased.nodes > 0
|
||||
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
|
||||
: typed.places > 0 || typed.nodes > 0
|
||||
? `${typed.places} struct(s) and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.`
|
||||
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
|
||||
? `${typed.places} struct(s)${lists} and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.`
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
|
||||
@@ -106,6 +106,9 @@ export function documentHooks({ state, editor }: HookContext) {
|
||||
removeAt: (where: ListPlace, title: string, index: number) =>
|
||||
listIn(where, title)?.removeAt(index) ?? null,
|
||||
moreAt: (where: ListPlace, title: string) => listIn(where, title)?.moreAt() ?? null,
|
||||
/** The Type picker of a struct item in a mixed list, or of a struct field inside it (#254). */
|
||||
typeAt: (where: ListPlace, title: string, index: number, field = '') =>
|
||||
listIn(where, title)?.typeAt(index, field) ?? 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;
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
type VariablesAsset,
|
||||
} from '../../core/model/Asset';
|
||||
import {
|
||||
MakeListItemTypeCommand,
|
||||
MakeStructTypeCommand,
|
||||
SetListItemTypeCommand,
|
||||
SetStructTypeCommand,
|
||||
WithStructTypes,
|
||||
} from '../../core/commands/structTypeCommands';
|
||||
@@ -90,7 +92,9 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe
|
||||
.flatMap((def): string[] =>
|
||||
def.kind === 'struct'
|
||||
? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)]
|
||||
: [],
|
||||
: def.kind === 'list'
|
||||
? [structAssetOf(project, def.itemType)?.name ?? '']
|
||||
: [],
|
||||
)
|
||||
.join(',');
|
||||
}
|
||||
@@ -98,8 +102,14 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe
|
||||
export function defSignature(def: PropertyDef | StructFieldDef): string {
|
||||
// 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('+') : '';
|
||||
// A type decides which of those rows may be changed, and names itself in a row (#253).
|
||||
const type = def.kind === 'struct' && def.structType ? `@${def.structType}` : '';
|
||||
// A type decides which of those rows may be changed, and names itself in a row (#253) – so does
|
||||
// the item type of a list (#254).
|
||||
const type =
|
||||
def.kind === 'struct' && def.structType
|
||||
? `@${def.structType}`
|
||||
: def.kind === 'list' && def.itemType
|
||||
? `@${def.itemType}`
|
||||
: '';
|
||||
return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
@@ -311,6 +321,26 @@ function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: strin
|
||||
];
|
||||
return entries;
|
||||
},
|
||||
listMenu: (path, itemType) => {
|
||||
const current = structAssetOf(project(), itemType);
|
||||
const target = { ownerId: owner.id, key, path };
|
||||
// A list starts empty, so unlike a field a list may hold the type it sits in.
|
||||
const types = project().assets.filter((a) => a.type === 'struct');
|
||||
return [
|
||||
{ label: 'Mixed', current: !current, onSelect: () => run(new SetListItemTypeCommand(target, '')) },
|
||||
...types.map((type) => ({
|
||||
label: type.name,
|
||||
current: type.id === current?.id,
|
||||
onSelect: () => run(new SetListItemTypeCommand(target, type.id)),
|
||||
})),
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Make struct type',
|
||||
disabled: current !== undefined,
|
||||
onSelect: () => run(new MakeListItemTypeCommand(target)),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -317,14 +317,10 @@ export function withBelow(row: Widget, field: Field, depth: number): Widget {
|
||||
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.
|
||||
*/
|
||||
/** A widget `depth` indent steps in, as far as a struct field of that depth. */
|
||||
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)));
|
||||
if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap)));
|
||||
line.add(widget, 1);
|
||||
return line;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface StructTyping {
|
||||
/** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */
|
||||
typeName: (structType: string | undefined) => string | null;
|
||||
menu: (path: readonly string[], structType: string | undefined) => MenuEntry[];
|
||||
/** The item type of a list (#254); `path` leads to the list field, none for a list variable. */
|
||||
listMenu: (path: readonly string[], itemType: string | undefined) => MenuEntry[];
|
||||
}
|
||||
|
||||
export interface VariableOptions {
|
||||
@@ -105,6 +107,8 @@ export function buildVariable(
|
||||
if (edit) head.add(removeButton(edit.remove));
|
||||
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
|
||||
host.body.add(head);
|
||||
if (def.kind === 'list' && typing)
|
||||
host.body.add(itemsRow(1, typing.typeName(def.itemType), () => typing.listMenu([], def.itemType)));
|
||||
if (below) host.body.add(indentedBy(below, 1));
|
||||
|
||||
if (def.kind === 'struct') {
|
||||
@@ -190,6 +194,14 @@ function structFieldRows(
|
||||
setters.set(field.key, addValue(row, sub));
|
||||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||
host.body.add(row);
|
||||
if (field.kind === 'list' && typing) {
|
||||
const inner = [...path, field.key];
|
||||
host.body.add(
|
||||
itemsRow(path.length + 2, typing.typeName(field.itemType), () =>
|
||||
typing.listMenu(inner, field.itemType),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
|
||||
}
|
||||
return (next) => {
|
||||
@@ -205,9 +217,18 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable
|
||||
|
||||
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
|
||||
function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||||
const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' });
|
||||
return pickerRow(depth, 'Type', typeName ?? 'Own fields', menu);
|
||||
}
|
||||
|
||||
/** Which Struct asset every item of a list is (#254), as a row of its own under the list's head. */
|
||||
function itemsRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
|
||||
return pickerRow(depth, 'Items', typeName ?? 'Mixed', menu);
|
||||
}
|
||||
|
||||
function pickerRow(depth: number, label: string, text: string, menu: () => MenuEntry[]): HStack {
|
||||
const picker = new Dropdown(text, { variant: 'ghost' });
|
||||
picker.onClick = () => new Menu(menu()).openBelow(picker);
|
||||
const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
|
||||
const name = new Label(label, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
|
||||
const row = gridRow(depth, new Spacer(MARK_WIDTH), name);
|
||||
row.add(picker, 1);
|
||||
return row;
|
||||
|
||||
+186
-29
@@ -5,7 +5,9 @@
|
||||
* step further in.
|
||||
*
|
||||
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
|
||||
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit.
|
||||
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. A typed list
|
||||
* (#254) is the exception: its items are its type, fixed – and in a mixed list a struct item may pick
|
||||
* a type of its own, which is a value edit again, since the item carries it.
|
||||
*/
|
||||
import {
|
||||
Button,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
ScrollContainer,
|
||||
Spacer,
|
||||
TextField,
|
||||
@@ -25,11 +28,16 @@ import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
itemTypeOf,
|
||||
type ListValue,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
untag,
|
||||
withItemType,
|
||||
} from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
||||
import { nextFieldName } from '../../core/commands/naming';
|
||||
import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||
import {
|
||||
@@ -39,22 +47,23 @@ import {
|
||||
structItemFields,
|
||||
structItemOf,
|
||||
} from '../../core/properties/listItems';
|
||||
import { flatDef } from '../../core/properties/PropertySchema';
|
||||
import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema';
|
||||
import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
|
||||
import {
|
||||
addValue,
|
||||
createField,
|
||||
type FieldContext,
|
||||
FIELD_INDENT,
|
||||
MARK_WIDTH,
|
||||
nameWidth,
|
||||
removeButton,
|
||||
} from '../inspector/fieldFactory';
|
||||
import { kindMenu, markPicker } from '../inspector/variableRows';
|
||||
import { kindMenu, markLabel, markPicker } from '../inspector/variableRows';
|
||||
import { shownItems } from '../variables/target';
|
||||
import type { ListAccess } from './slot';
|
||||
|
||||
/** Two digits; a longer index widens its own label. */
|
||||
const INDEX_WIDTH = 16;
|
||||
/** Room for three digits; a longer index widens its own label. */
|
||||
const INDEX_WIDTH = 28;
|
||||
|
||||
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
|
||||
interface RowWidgets {
|
||||
@@ -64,6 +73,12 @@ interface RowWidgets {
|
||||
parts: Map<string, Widget>;
|
||||
marks: Map<string, Widget>;
|
||||
names: Map<string, Widget>;
|
||||
/** The kind each struct field is drawn as – the type's where it has one (#254). */
|
||||
kinds: Map<string, NestedKind>;
|
||||
/** The Type picker of the struct item (`''`) and of struct fields inside it. */
|
||||
types: Map<string, Widget>;
|
||||
/** A struct item drawn with its type's fields: no names to change, no + Field. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
|
||||
@@ -72,6 +87,9 @@ const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
|
||||
parts: new Map(),
|
||||
marks: new Map(),
|
||||
names: new Map(),
|
||||
kinds: new Map(),
|
||||
types: new Map(),
|
||||
locked: false,
|
||||
});
|
||||
|
||||
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
|
||||
@@ -102,9 +120,13 @@ export class ListHead extends HStack {
|
||||
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);
|
||||
// Built on click, not once: which kinds fit depends on the list as it is then. A typed list
|
||||
// has one kind only, so there is nothing to pick.
|
||||
this.addButton.onClick = () => {
|
||||
const now = access();
|
||||
if (now?.itemType) add('struct');
|
||||
else new Menu(kindMenu(now?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton);
|
||||
};
|
||||
this.add(this.addButton);
|
||||
this.setCount(now?.value.length ?? 0);
|
||||
}
|
||||
@@ -149,10 +171,20 @@ export class InlineList extends VStack {
|
||||
return this.access()?.title ?? '';
|
||||
}
|
||||
|
||||
/** Adds an item of `kind` at the end – the head's `+ Item`. */
|
||||
/** Adds an item of `kind` at the end – the head's `+ Item`; in a typed list one of its type. */
|
||||
addItem(kind: NestedKind): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
|
||||
if (!access) return;
|
||||
const type = this.listType(access);
|
||||
access.replace([...access.value, type ? typedItem(this.project, type) : makeItem(kind, undefined)]);
|
||||
}
|
||||
|
||||
private get project(): Project {
|
||||
return this.ctx.project();
|
||||
}
|
||||
|
||||
private listType(access: ListAccess) {
|
||||
return structAssetOf(this.project, access.itemType);
|
||||
}
|
||||
|
||||
/** Re-reads the list: new values go into the rows, a new structure builds them again. */
|
||||
@@ -193,6 +225,11 @@ export class InlineList extends VStack {
|
||||
return centreOf(this.rowWidgets[index]?.names.get(field));
|
||||
}
|
||||
|
||||
/** For the check scripts: the Type picker of a struct item, or of a struct field inside it. */
|
||||
typeAt(index: number, field = ''): { x: number; y: number } | null {
|
||||
return centreOf(this.rowWidgets[index]?.types.get(field));
|
||||
}
|
||||
|
||||
/** For the check scripts: the "… N more" line, which opens the list in its tab. */
|
||||
moreAt(): { x: number; y: number } | null {
|
||||
return centreOf(this.more ?? undefined);
|
||||
@@ -206,10 +243,28 @@ export class InlineList extends VStack {
|
||||
offers: NestedKind[];
|
||||
rows: number;
|
||||
more: number;
|
||||
itemType: string;
|
||||
/** The type each item is marked with, `''` for none. */
|
||||
types: string[];
|
||||
/** Per drawn row: whether its fields are the type's, fixed. */
|
||||
locked: boolean[];
|
||||
/** Per drawn row: the kind each struct field is drawn as, by its dotted id. */
|
||||
fieldKinds: Record<string, NestedKind>[];
|
||||
} {
|
||||
const access = this.access();
|
||||
const base = { rows: this.rowWidgets.length, more: 0 };
|
||||
if (!access) return { ...base, title: '', items: [], kinds: [], offers: [] };
|
||||
if (!access)
|
||||
return {
|
||||
...base,
|
||||
title: '',
|
||||
items: [],
|
||||
kinds: [],
|
||||
offers: [],
|
||||
itemType: '',
|
||||
types: [],
|
||||
locked: [],
|
||||
fieldKinds: [],
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
|
||||
@@ -218,6 +273,10 @@ export class InlineList extends VStack {
|
||||
items: access.value.map((item) => textOf(item)),
|
||||
kinds: access.value.map(itemKindOf),
|
||||
offers: [...access.kinds],
|
||||
itemType: access.itemType ?? '',
|
||||
types: access.value.map((item) => itemTypeOf(item) ?? ''),
|
||||
locked: this.rowWidgets.map((row) => row.locked),
|
||||
fieldKinds: this.rowWidgets.map((row) => Object.fromEntries(row.kinds)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,8 +284,14 @@ export class InlineList extends VStack {
|
||||
private signatureOf(access: ListAccess | null): string {
|
||||
if (!access) return '';
|
||||
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}`;
|
||||
// A type's name is on screen too, so a renamed or deleted type has to rebuild the rows.
|
||||
const typeName = (id: string | undefined) =>
|
||||
id ? `@${structAssetOf(this.project, id)?.name ?? ''}` : '';
|
||||
const shape = access.value
|
||||
.slice(0, shown)
|
||||
.map((item) => shapeOf(item, typeName))
|
||||
.join(',');
|
||||
return `${access.title}|${typeName(access.itemType)}|${shape}|${access.value.length}`;
|
||||
}
|
||||
|
||||
private scrollArea(): ScrollContainer | null {
|
||||
@@ -276,7 +341,8 @@ export class InlineList extends VStack {
|
||||
label.minWidth = INDEX_WIDTH;
|
||||
if (tall) label.minHeight = Theme.rowHeight;
|
||||
row.add(label);
|
||||
const mark = this.kindPicker(access, [index], kind);
|
||||
// A typed list's items are its type: there is no other kind to turn one into.
|
||||
const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind);
|
||||
if (tall) mark.minHeight = Theme.rowHeight;
|
||||
row.add(mark);
|
||||
let below: Widget | null = null;
|
||||
@@ -339,12 +405,20 @@ export class InlineList extends VStack {
|
||||
);
|
||||
}
|
||||
|
||||
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
|
||||
/**
|
||||
* A struct item carries its own shape, so its fields are named, typed and removed right here –
|
||||
* unless it has a type (#254): of the list, or picked for the item. Then its fields are the type's.
|
||||
*/
|
||||
private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void {
|
||||
const stack = new VStack({ gap: Theme.gap });
|
||||
const widgets = rowWidgets(mark, stack);
|
||||
const leaves: Leaf[] = [];
|
||||
this.addStructFields(access, stack, [index], 0, widgets, leaves);
|
||||
const item = valueAt(access.value, [index]);
|
||||
const typed = this.listType(access) !== undefined;
|
||||
if (!typed && access.typable) stack.add(this.typeLine(access, [index], 0, widgets, ''));
|
||||
const declared = this.declaredFields(this.listType(access)?.id ?? itemTypeOf(item));
|
||||
widgets.locked = declared !== undefined;
|
||||
this.addStructFields(access, stack, [index], 0, widgets, leaves, declared);
|
||||
row.add(stack, 1);
|
||||
this.setters.push((next) => {
|
||||
for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? '');
|
||||
@@ -352,10 +426,19 @@ export class InlineList extends VStack {
|
||||
this.rowWidgets.push(widgets);
|
||||
}
|
||||
|
||||
/** The fields of a Struct asset, or none for a struct of its own fields. */
|
||||
private declaredFields(typeId: string | undefined): readonly StructFieldDef[] | undefined {
|
||||
const type = structAssetOf(this.project, typeId);
|
||||
return type ? typeFields(type) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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), and so does the item list of a list field. Check
|
||||
* scripts find a nested field by its keys joined with dots (`loot.count`).
|
||||
*
|
||||
* `declared` are the type's fields when the struct has one: its shape is fixed then, and a field
|
||||
* is the kind the type says – an item keeps an asset id as a plain text, which alone reads as one.
|
||||
*/
|
||||
private addStructFields(
|
||||
access: ListAccess,
|
||||
@@ -364,17 +447,24 @@ export class InlineList extends VStack {
|
||||
depth: number,
|
||||
widgets: RowWidgets,
|
||||
leaves: Leaf[],
|
||||
declared: readonly StructFieldDef[] | undefined,
|
||||
): void {
|
||||
const locked = declared !== undefined;
|
||||
// A field value edit does not rebuild the row, so every button reads the struct as it is now.
|
||||
const now = () => structItemOf(valueAt(access.value, path));
|
||||
for (const field of structItemFields(valueAt(access.value, path))) {
|
||||
for (const own of structItemFields(valueAt(access.value, path))) {
|
||||
const decl = declared?.find((d) => d.key === own.key);
|
||||
const field = { key: own.key, kind: declaredKind(own.kind, decl) };
|
||||
const fieldPath = [...path, field.key];
|
||||
const id = fieldPath.slice(1).join('.');
|
||||
const line = indentedLine(depth);
|
||||
const fieldMark = this.kindPicker(access, fieldPath, field.kind);
|
||||
const name = nameField(field.key, depth, (to) => {
|
||||
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
|
||||
});
|
||||
// The fields of a typed struct are the type's: its values change here, its shape on the asset.
|
||||
const fieldMark = locked ? markLabel(field.kind) : this.kindPicker(access, fieldPath, field.kind);
|
||||
const name = locked
|
||||
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
|
||||
: nameField(field.key, depth, (to) => {
|
||||
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
|
||||
});
|
||||
line.add(fieldMark);
|
||||
line.add(name);
|
||||
let below: Widget | null = null;
|
||||
@@ -391,19 +481,73 @@ export class InlineList extends VStack {
|
||||
widgets.parts.set(id, sub.widget);
|
||||
}
|
||||
widgets.marks.set(id, fieldMark);
|
||||
widgets.kinds.set(id, field.kind);
|
||||
widgets.names.set(id, name);
|
||||
line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
|
||||
if (!locked) line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
|
||||
stack.add(line);
|
||||
// Items under the field's own line: their index column is the step in.
|
||||
if (below) stack.add(indented(below, depth * (FIELD_INDENT + Theme.gap)));
|
||||
if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves);
|
||||
// Items under the field's own line, one step further in than the field.
|
||||
if (below) stack.add(indented(below, (depth + 1) * (FIELD_INDENT + Theme.gap)));
|
||||
if (field.kind === 'struct') {
|
||||
// A struct in a struct of its own fields may take a type as well; inside a type it is the type's.
|
||||
if (!locked && access.typable) stack.add(this.typeLine(access, fieldPath, depth + 1, widgets, id));
|
||||
const inner =
|
||||
decl?.kind === 'struct'
|
||||
? (this.declaredFields(decl.structType) ?? decl.fields)
|
||||
: locked
|
||||
? []
|
||||
: this.declaredFields(itemTypeOf(valueAt(access.value, fieldPath)));
|
||||
this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves, inner);
|
||||
}
|
||||
}
|
||||
if (locked) return;
|
||||
const foot = indentedLine(depth);
|
||||
foot.add(addFieldMenu((kind) => this.addField(access, path, kind)));
|
||||
foot.add(new Spacer(), 1);
|
||||
stack.add(foot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Struct asset the struct at `path` follows (#254): its own fields, or a type – picking one
|
||||
* brings the struct into that shape, keeping what it has of the type's fields.
|
||||
*/
|
||||
private typeLine(
|
||||
access: ListAccess,
|
||||
path: ValuePath,
|
||||
depth: number,
|
||||
widgets: RowWidgets,
|
||||
id: string,
|
||||
): HStack {
|
||||
const current = structAssetOf(this.project, itemTypeOf(valueAt(access.value, path)));
|
||||
const picker = new Dropdown(current?.name ?? 'Own fields', { variant: 'ghost' });
|
||||
const struct = (): StructValue => structItemOf(valueAt(access.value, path));
|
||||
picker.onClick = () => {
|
||||
const entries: MenuEntry[] = [
|
||||
{
|
||||
label: 'Own fields',
|
||||
current: !current,
|
||||
onSelect: () => this.replaceAt(access, path, withItemType(struct(), '')),
|
||||
},
|
||||
...this.project.assets
|
||||
.filter((a) => a.type === 'struct')
|
||||
.map((type) => ({
|
||||
label: type.name,
|
||||
current: type.id === current?.id,
|
||||
onSelect: () => {
|
||||
const asset = structAssetOf(this.project, type.id);
|
||||
if (asset) this.replaceAt(access, path, typedItem(this.project, asset, struct()));
|
||||
},
|
||||
})),
|
||||
];
|
||||
new Menu(entries).openBelow(picker);
|
||||
};
|
||||
widgets.types.set(id, picker);
|
||||
const line = indentedLine(depth);
|
||||
line.add(new Spacer(MARK_WIDTH));
|
||||
line.add(new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }));
|
||||
line.add(picker, 1);
|
||||
return line;
|
||||
}
|
||||
|
||||
private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void {
|
||||
const bundle = valueAt(access.value, path);
|
||||
const key = nextFieldName(structItemFields(bundle));
|
||||
@@ -417,6 +561,16 @@ export class InlineList extends VStack {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind a field is drawn as: the declared one for a value that is not a list or a struct – a
|
||||
* struct or list stays what its value is until the type brings it into shape.
|
||||
*/
|
||||
function declaredKind(own: NestedKind, decl: StructFieldDef | undefined): NestedKind {
|
||||
if (!decl || own === 'list' || own === 'struct' || decl.kind === 'list' || decl.kind === 'struct')
|
||||
return own;
|
||||
return decl.kind;
|
||||
}
|
||||
|
||||
/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */
|
||||
interface Leaf {
|
||||
within: ValuePath;
|
||||
@@ -465,11 +619,14 @@ const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem
|
||||
return next;
|
||||
};
|
||||
|
||||
/** An item's kind, and for a struct the shape of every field below it – what its rows are built from. */
|
||||
function shapeOf(item: PropertyValue | undefined): string {
|
||||
/**
|
||||
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
|
||||
* are built from.
|
||||
*/
|
||||
function shapeOf(item: PropertyValue | undefined, typeName: (id: string | undefined) => string): string {
|
||||
const kind = itemKindOf(item);
|
||||
if (kind !== 'struct' || !isStruct(item)) return kind;
|
||||
return `struct(${structItemFields(item)
|
||||
.map((f) => `${f.key}:${shapeOf(item[f.key])}`)
|
||||
return `struct${typeName(itemTypeOf(item))}(${structItemFields(item)
|
||||
.map((f) => `${f.key}:${shapeOf(item[f.key], typeName)}`)
|
||||
.join(',')})`;
|
||||
}
|
||||
+13
-1
@@ -9,6 +9,7 @@ import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { listItemTypeAt } from '../../core/model/structTypes';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
@@ -56,6 +57,10 @@ export interface ListAccess {
|
||||
sub(path: ValuePath): ListAccess | null;
|
||||
/** A whole new list in one command – adding, deleting, and the bulk text field. */
|
||||
replace(next: ListValue): void;
|
||||
/** The Struct asset every item is (#254), declared on the list or on the typed item it sits in. */
|
||||
readonly itemType: string | undefined;
|
||||
/** Whether a struct item may pick a type of its own – not in a node's list, which a run reads as it is. */
|
||||
readonly typable: boolean;
|
||||
}
|
||||
|
||||
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
|
||||
@@ -86,6 +91,7 @@ const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ?
|
||||
/** The owner's own list, or – with a `path` – a list nested inside its value. */
|
||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot): ListAccess | null {
|
||||
const path = slot.path ?? [];
|
||||
const itemType = () => listItemTypeAt(state.project, owner.def, owner.current(), path);
|
||||
const nested: Nested = {
|
||||
open:
|
||||
slot.kind === 'node'
|
||||
@@ -95,7 +101,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot
|
||||
};
|
||||
if (path.length === 0)
|
||||
return owner.def.kind === 'list'
|
||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested)
|
||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested, itemType)
|
||||
: null;
|
||||
// A struct is brought into its declared shape first, so a declared list not stored yet is there.
|
||||
const def = owner.def;
|
||||
@@ -126,6 +132,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot
|
||||
commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)),
|
||||
},
|
||||
nested,
|
||||
itemType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,6 +154,7 @@ function access<Target>(
|
||||
current: () => ListValue,
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>,
|
||||
nested: Nested,
|
||||
itemType: () => string | undefined,
|
||||
): ListAccess {
|
||||
// The value keeps the kind it has: a color item written from its field stays a color.
|
||||
const handlersAt = (path: ValuePath): FieldHandlers =>
|
||||
@@ -174,6 +182,10 @@ function access<Target>(
|
||||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||||
// A preview does not, which is why `toModel` above has to.
|
||||
replace: (next) => state.execute(spec.commit(next)),
|
||||
get itemType() {
|
||||
return itemType();
|
||||
},
|
||||
typable: nested.open !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from
|
||||
import { newId } from '../core/ids';
|
||||
import type { Asset, TilemapAsset } from '../core/model/Asset';
|
||||
import { withoutObjects } from '../core/graph/liveObjects';
|
||||
import { type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties';
|
||||
import { bagWithoutItemTypes, type PropertyBag, sameValue, type Vec2 } from '../core/model/Properties';
|
||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||
import { treeOrder } from '../core/model/hierarchy';
|
||||
@@ -249,7 +249,7 @@ export class World {
|
||||
entity.x = object.position.x;
|
||||
entity.y = object.position.y;
|
||||
entity.z = object.position.z;
|
||||
const resolved = resolveSceneObjectProps(this.project, asset, object);
|
||||
const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
|
||||
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
|
||||
// The scene only ever got the objects' empty places, so a value that differs by them alone stays.
|
||||
for (const [key, value] of Object.entries(resolved))
|
||||
@@ -320,7 +320,7 @@ export class World {
|
||||
if (!entity || !asset) continue;
|
||||
const p = object.position;
|
||||
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
|
||||
const base = resolveSceneObjectProps(this.project, asset, object);
|
||||
const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
|
||||
// What a run holds of an object stays in the run; the scene gets the empty one.
|
||||
const drifted = Object.keys(entity.props).filter(
|
||||
(k) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type GraphLookup, NO_LOOKUP } from '../../src/core/graph/lookup';
|
||||
import { AddItem } from '../../src/core/graph/nodes/data/AddItem';
|
||||
import { Fill } from '../../src/core/graph/nodes/data/Fill';
|
||||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||||
@@ -13,10 +14,18 @@ const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y })
|
||||
/** A `list` variable holding struct items, the way a seeder writes it. */
|
||||
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never);
|
||||
|
||||
/** The node interface only needs `run`; nothing here reads the context. */
|
||||
/** The node interface only needs `run`; nothing here reads the context, and no node has a setting. */
|
||||
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||||
(def.run as (ctx: unknown, i: Record<string, unknown>) => { outputs: Record<string, unknown> })({}, inputs)
|
||||
.outputs;
|
||||
(
|
||||
def.run as (
|
||||
ctx: unknown,
|
||||
i: Record<string, unknown>,
|
||||
params: Record<string, unknown>,
|
||||
def: unknown,
|
||||
entry: string,
|
||||
lookup: GraphLookup,
|
||||
) => { outputs: Record<string, unknown> }
|
||||
)({}, inputs, {}, def, '', NO_LOOKUP).outputs;
|
||||
|
||||
describe('a list whose items are structs', () => {
|
||||
/** Each item keeps the fields it carries: there is no declaration left to fill or drop any. */
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('a struct typed by a Struct asset', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = dropper(f, loot);
|
||||
expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 1, nodes: 0 });
|
||||
expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 1, lists: 0, nodes: 0 });
|
||||
f.stack.execute(new DeleteAssetCommand(loot.id));
|
||||
expect(drop(chest).fields.map((x) => x.key)).toEqual(['Name', 'Amount']);
|
||||
expect(chest.props.drop).toEqual({ Name: 'Coin', Amount: 1 });
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Typed lists (#254): a list naming a Struct asset holds items of that type only, each marked with
|
||||
* it, kept in its shape as the type changes. A struct item in a mixed list may carry a type of its
|
||||
* own. A run never sees the mark, and `Add item` with a type takes a fitting struct only.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
MakeListItemTypeCommand,
|
||||
SetListItemTypeCommand,
|
||||
SetStructTypeCommand,
|
||||
WithStructTypes,
|
||||
} from '../../src/core/commands/structTypeCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import type { Command } from '../../src/core/commands/Command';
|
||||
import { declaredVariables } from '../../src/core/graph/sharedState';
|
||||
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { ITEM_TYPE_KEY, withoutItemTypes } from '../../src/core/model/Properties';
|
||||
import { listItemTypeAt, structTypeUsages, typedItem } from '../../src/core/model/structTypes';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import {
|
||||
fakeContext,
|
||||
makeBehavior,
|
||||
makeSpriteScene,
|
||||
makeVariables,
|
||||
place,
|
||||
runDef,
|
||||
type SceneFixture,
|
||||
} from '../helpers';
|
||||
|
||||
/** `Loot` = { Name: "Coin", Amount: 1 } */
|
||||
function lootType(f: SceneFixture): StructAsset {
|
||||
const create = new CreateAssetCommand('struct', 'Loot');
|
||||
f.stack.execute(create);
|
||||
const id = create.asset.id;
|
||||
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
|
||||
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'Name', 'Coin'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1));
|
||||
return create.asset as StructAsset;
|
||||
}
|
||||
|
||||
/** A behavior with a list variable `loots` holding `items`. */
|
||||
function chestWith(f: SceneFixture, items: unknown[]): BehaviorAsset {
|
||||
const chest = makeBehavior(f, 'Chest');
|
||||
f.stack.execute(new AddVariableCommand(chest.id, 'loots', 'list'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(chest.id, 'loots', items as never));
|
||||
return chest;
|
||||
}
|
||||
|
||||
const loots = (chest: BehaviorAsset) => chest.variables.find((v) => v.key === 'loots')!;
|
||||
const target = (chest: BehaviorAsset, path: string[] = []) => ({ ownerId: chest.id, key: 'loots', path });
|
||||
const onType = (
|
||||
f: SceneFixture,
|
||||
command: Command,
|
||||
rename?: ConstructorParameters<typeof WithStructTypes>[1],
|
||||
) => f.stack.execute(new WithStructTypes(command, rename));
|
||||
const rename = (loot: StructAsset, from: string, to: string) => ({ structType: loot.id, path: [], from, to });
|
||||
|
||||
describe('a list typed by a Struct asset', () => {
|
||||
it('brings every item into the type, marked with it, keeping what fits', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem', Old: 4 }, {}]);
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id));
|
||||
expect(loots(chest)).toMatchObject({ itemType: loot.id });
|
||||
expect(chest.props.loots).toEqual([
|
||||
{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id },
|
||||
{ Name: 'Coin', Amount: 1, [ITEM_TYPE_KEY]: loot.id },
|
||||
]);
|
||||
f.stack.undo();
|
||||
expect(loots(chest)).not.toHaveProperty('itemType');
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem', Old: 4 }, {}]);
|
||||
});
|
||||
|
||||
it('refuses a list holding anything but structs, and changes nothing', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem' }, 3, 'x']);
|
||||
expect(() => f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id))).toThrow(
|
||||
'loots holds 2 items that are no struct – only a list of structs can take a type',
|
||||
);
|
||||
expect(loots(chest)).not.toHaveProperty('itemType');
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem' }, 3, 'x']);
|
||||
});
|
||||
|
||||
it('looks at the overrides too: a placed object holding a number refuses the type', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem' }]);
|
||||
const placed = place(f, chest.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, placed, 'loots', [{ Name: 'Ruby' }, 5]));
|
||||
expect(() => f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id))).toThrow(
|
||||
/1 item that is/,
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the type: a field added, renamed and removed reaches every item, the overrides too', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem', Amount: 7 }]);
|
||||
const placed = place(f, chest.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, placed, 'loots', [{ Name: 'Ruby', Amount: 3 }]));
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id));
|
||||
onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean'));
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
onType(f, new RenameVariableCommand(loot.id, 'Amount', 'Count'), rename(loot, 'Amount', 'Count'));
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem', Count: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
expect(f.object(placed).overrides.loots).toEqual([
|
||||
{ Name: 'Ruby', Count: 3, Rare: false, [ITEM_TYPE_KEY]: loot.id },
|
||||
]);
|
||||
onType(f, new RemoveVariableCommand(loot.id, 'Name'));
|
||||
expect(chest.props.loots).toEqual([{ Count: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 7, Rare: false, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
});
|
||||
|
||||
it('goes back to mixed with the items keeping their marks, so nothing is lost', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem' }]);
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id));
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), ''));
|
||||
expect(loots(chest)).not.toHaveProperty('itemType');
|
||||
expect(chest.props.loots).toEqual([{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
expect(structTypeUsages(f.project, loot.id).lists).toBe(0);
|
||||
});
|
||||
|
||||
it('types a list field inside a struct variable, and a list inside a typed item follows its type', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = makeBehavior(f, 'Chest');
|
||||
f.stack.execute(new AddVariableCommand(chest.id, 'room', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(chest.id, 'room', { key: 'loots', kind: 'list' }));
|
||||
f.stack.execute(new SetAssetPropertyCommand(chest.id, 'room', { loots: [{ Name: 'Gem' }] }));
|
||||
f.stack.execute(new SetListItemTypeCommand({ ownerId: chest.id, key: 'room', path: ['loots'] }, loot.id));
|
||||
expect(chest.props.room).toEqual({ loots: [{ Name: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id }] });
|
||||
expect(structTypeUsages(f.project, loot.id)).toEqual({ places: 0, lists: 1, nodes: 0 });
|
||||
// A Bag type whose `inside` is a list of Loot: a Bag item's `inside` is typed through it.
|
||||
const bag = new CreateAssetCommand('struct', 'Bag');
|
||||
f.stack.execute(bag);
|
||||
f.stack.execute(new AddVariableCommand(bag.asset.id, 'inside', 'list'));
|
||||
f.stack.execute(new SetListItemTypeCommand({ ownerId: bag.asset.id, key: 'inside', path: [] }, loot.id));
|
||||
const holder = chestWith(f, [{ inside: [{ Name: 'Axe' }] }]);
|
||||
f.stack.execute(new SetListItemTypeCommand(target(holder), bag.asset.id));
|
||||
expect(holder.props.loots).toEqual([
|
||||
{ inside: [{ Name: 'Axe', Amount: 1, [ITEM_TYPE_KEY]: loot.id }], [ITEM_TYPE_KEY]: bag.asset.id },
|
||||
]);
|
||||
expect(listItemTypeAt(f.project, loots(holder), holder.props.loots, [0, 'inside'])).toBe(loot.id);
|
||||
expect(listItemTypeAt(f.project, loots(holder), holder.props.loots, [])).toBe(bag.asset.id);
|
||||
});
|
||||
|
||||
it('makes a type from its items: every field any item has, at the first value it has', () => {
|
||||
const f = makeSpriteScene();
|
||||
const chest = chestWith(f, [
|
||||
{ Name: 'Gem', Amount: 2 },
|
||||
{ Name: 'Axe', Rare: true },
|
||||
]);
|
||||
const make = new MakeListItemTypeCommand(target(chest));
|
||||
f.stack.execute(make);
|
||||
const type = f.project.assets.find((a) => a.id === make.assetId) as StructAsset;
|
||||
expect(type).toMatchObject({
|
||||
type: 'struct',
|
||||
name: 'loots',
|
||||
props: { Name: 'Gem', Amount: 2, Rare: true },
|
||||
});
|
||||
expect(type.variables.map((v) => `${v.key}:${v.kind}`)).toEqual([
|
||||
'Name:text',
|
||||
'Amount:number',
|
||||
'Rare:boolean',
|
||||
]);
|
||||
expect(chest.props.loots).toEqual([
|
||||
{ Name: 'Gem', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id },
|
||||
{ Name: 'Axe', Amount: 2, Rare: true, [ITEM_TYPE_KEY]: type.id },
|
||||
]);
|
||||
f.stack.undo();
|
||||
expect(f.project.assets.some((a) => a.id === make.assetId)).toBe(false);
|
||||
expect(chest.props.loots).toEqual([
|
||||
{ Name: 'Gem', Amount: 2 },
|
||||
{ Name: 'Axe', Rare: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('survives saving and loading, and a stale item in a file is brought in line on load', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem' }]);
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id));
|
||||
const raw = serializeProject(f.project) as { assets: Record<string, unknown>[] };
|
||||
const stored = raw.assets.find((a) => a.id === chest.id) as {
|
||||
variables: Record<string, unknown>[];
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
expect(stored.variables[0]).toMatchObject({ itemType: loot.id });
|
||||
stored.props.loots = [{ Name: 'Ruby', Stale: 1 }];
|
||||
const reloaded = deserializeProject(raw).assets.find((a) => a.id === chest.id) as BehaviorAsset;
|
||||
expect(reloaded.props.loots).toEqual([{ Name: 'Ruby', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a struct item with a type of its own, in a mixed list', () => {
|
||||
it('follows its type while the other items are left alone', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [typedItem(f.project, loot, { Name: 'Gem' }), { Name: 'Own', x: 1 }, 5]);
|
||||
onType(f, new RenameVariableCommand(loot.id, 'Name', 'Title'), rename(loot, 'Name', 'Title'));
|
||||
expect(chest.props.loots).toEqual([
|
||||
{ Title: 'Gem', Amount: 1, [ITEM_TYPE_KEY]: loot.id },
|
||||
{ Name: 'Own', x: 1 },
|
||||
5,
|
||||
]);
|
||||
});
|
||||
|
||||
it('reaches a typed struct nested inside an item of its own fields', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ prize: { Name: 'Gem', [ITEM_TYPE_KEY]: loot.id } }]);
|
||||
expect(chest.props.loots).toEqual([{ prize: { Name: 'Gem', [ITEM_TYPE_KEY]: loot.id } }]);
|
||||
onType(f, new AddVariableCommand(loot.id, 'Rare', 'boolean'));
|
||||
expect(chest.props.loots).toEqual([
|
||||
{ prize: { Name: 'Gem', Amount: 1, Rare: false, [ITEM_TYPE_KEY]: loot.id } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('is typed inside a typed struct variable too', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = makeBehavior(f, 'Chest');
|
||||
f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(chest.id, 'drop', { key: 'more', kind: 'list' }));
|
||||
f.stack.execute(
|
||||
new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, ''),
|
||||
);
|
||||
f.stack.execute(new SetAssetPropertyCommand(chest.id, 'drop', { more: [typedItem(f.project, loot)] }));
|
||||
onType(f, new RemoveVariableCommand(loot.id, 'Amount'));
|
||||
expect(chest.props.drop).toEqual({ more: [{ Name: 'Coin', [ITEM_TYPE_KEY]: loot.id }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a run never sees the mark', () => {
|
||||
it('is taken out of variables, behavior defaults and placed objects', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const chest = chestWith(f, [{ Name: 'Gem' }]);
|
||||
f.stack.execute(new SetListItemTypeCommand(target(chest), loot.id));
|
||||
const shared = makeVariables(f, 'Globals', []);
|
||||
f.stack.execute(new AddVariableCommand(shared.id, 'bag', 'list'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(shared.id, 'bag', [typedItem(f.project, loot)]));
|
||||
expect(shared.props.bag).toEqual([{ Name: 'Coin', Amount: 1, [ITEM_TYPE_KEY]: loot.id }]);
|
||||
expect(declaredVariables(f.project)[shared.id]!.bag).toEqual([{ Name: 'Coin', Amount: 1 }]);
|
||||
expect(graphLookup(f.project).behavior(chest.id)!.defaults.loots).toEqual([{ Name: 'Gem', Amount: 1 }]);
|
||||
expect(withoutItemTypes({ a: [{ b: { [ITEM_TYPE_KEY]: 'x', c: 1 } }] })).toEqual({
|
||||
a: [{ b: { c: 1 } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add item with a type', () => {
|
||||
const run = (f: SceneFixture, loot: StructAsset, value: unknown) => {
|
||||
const ctx = fakeContext();
|
||||
const out = runDef(
|
||||
'AddItem',
|
||||
ctx,
|
||||
{ list: [{ Name: 'Gem', Amount: 1 }], value: value as never, index: -1 },
|
||||
{ structType: loot.id },
|
||||
'exec',
|
||||
graphLookup(f.project),
|
||||
).outputs;
|
||||
return { result: out?.result, logs: ctx.logs };
|
||||
};
|
||||
|
||||
it('adds a struct with exactly the fields of the type', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
expect(run(f, loot, { Amount: 3, Name: 'Axe' })).toEqual({
|
||||
result: [
|
||||
{ Name: 'Gem', Amount: 1 },
|
||||
{ Amount: 3, Name: 'Axe' },
|
||||
],
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the list as it was and says so for anything else', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const unchanged = [{ Name: 'Gem', Amount: 1 }];
|
||||
expect(run(f, loot, { Name: 'Axe' })).toEqual({
|
||||
result: unchanged,
|
||||
logs: ['Add item: Name: Axe is no Loot – not added'],
|
||||
});
|
||||
expect(run(f, loot, { Name: 'Axe', Amount: 1, Extra: 0 }).result).toEqual(unchanged);
|
||||
expect(run(f, loot, 7)).toEqual({ result: unchanged, logs: ['Add item: 7 is no Loot – not added'] });
|
||||
});
|
||||
|
||||
it('checks nothing without a type, or with one that is gone', () => {
|
||||
const f = makeSpriteScene();
|
||||
const out = runDef(
|
||||
'AddItem',
|
||||
fakeContext(),
|
||||
{ list: [], value: 7, index: -1 },
|
||||
{ structType: 'gone' },
|
||||
'exec',
|
||||
graphLookup(f.project),
|
||||
);
|
||||
expect(out.outputs?.result).toEqual([7]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Set item with a type', () => {
|
||||
const run = (f: SceneFixture, loot: StructAsset, value: unknown, structType = loot.id) => {
|
||||
const ctx = fakeContext();
|
||||
const out = runDef(
|
||||
'SetItem',
|
||||
ctx,
|
||||
{ list: [{ Name: 'Gem', Amount: 1 }, 5], index: 0, value: value as never },
|
||||
{ structType },
|
||||
'exec',
|
||||
graphLookup(f.project),
|
||||
).outputs;
|
||||
return { result: out?.result, logs: ctx.logs };
|
||||
};
|
||||
|
||||
it('sets a struct with exactly the fields of the type', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
expect(run(f, loot, { Amount: 3, Name: 'Axe' })).toEqual({
|
||||
result: [{ Amount: 3, Name: 'Axe' }, 5],
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the list as it was and says so for anything else', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
const unchanged = [{ Name: 'Gem', Amount: 1 }, 5];
|
||||
expect(run(f, loot, { Name: 'Axe' })).toEqual({
|
||||
result: unchanged,
|
||||
logs: ['Set item: Name: Axe is no Loot – not set'],
|
||||
});
|
||||
expect(run(f, loot, { Name: 'Axe', Amount: 1, Extra: 0 }).result).toEqual(unchanged);
|
||||
expect(run(f, loot, 7)).toEqual({ result: unchanged, logs: ['Set item: 7 is no Loot – not set'] });
|
||||
});
|
||||
|
||||
it('checks nothing without a type, or with one that is gone', () => {
|
||||
const f = makeSpriteScene();
|
||||
const loot = lootType(f);
|
||||
// Without a check the slot keeps its kind, as ever: a struct slot takes no number.
|
||||
expect(run(f, loot, { Other: 1 }, '').result).toEqual([{ Other: 1 }, 5]);
|
||||
expect(run(f, loot, { Other: 1 }, 'gone').logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Typed lists (#254), with the real mouse: a list variable picks a Struct asset under its head and
|
||||
* its items take the type; a list holding a number refuses and says so in the console; `+ Item`
|
||||
* adds one of the type straight away; a struct item in a mixed list picks a type of its own; a
|
||||
* rename on the asset reaches both; "Make struct type" turns a list's items into an asset. In a run
|
||||
* `Add item` with a type takes an item of that list and a Pack of it, and turns a number away. A
|
||||
* typed item's asset field is drawn as the asset picker its type declares, not as the text it holds.
|
||||
*
|
||||
* Everything it adds carries a `tl55` id or is an asset it made, and goes again at the end.
|
||||
*/
|
||||
import { inView, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
export async function typedLists(t) {
|
||||
const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t;
|
||||
const press_ = async (at, what) => {
|
||||
if (!at) throw new Error(`nothing to click: ${what}`);
|
||||
await click(at.x, at.y);
|
||||
await sleep(150);
|
||||
};
|
||||
/** A menu entry, always the last drawn: the panels behind a menu carry the same words. */
|
||||
const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label);
|
||||
/** Every hit of a text, top to bottom. */
|
||||
const all = async (text) =>
|
||||
((await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []).sort((a, b) => a.y - b.y);
|
||||
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`);
|
||||
const chest = () => asset('tl55-chest');
|
||||
const variable = async (key) => (await chest()).variables.find((v) => v.key === key);
|
||||
/** What the chest holds: its own value once one is set, else the declared default. */
|
||||
const value = async (key) => {
|
||||
const now = await chest();
|
||||
return now.props[key] ?? now.variables.find((v) => v.key === key).default;
|
||||
};
|
||||
/** Per drawn item of a list in the inspector: whether its fields are the type's, fixed. */
|
||||
const locked = async (key) =>
|
||||
((await json("__gm.values.lists('inspector')")) ?? []).find((l) => l.title === list(key))?.locked ?? null;
|
||||
const list = (key) => `Chest 55 · ${key}`;
|
||||
const MARK = '$struct';
|
||||
const LOOT = 'tl55-loot';
|
||||
const DROP = 'tl55-drop';
|
||||
|
||||
const node = (id, type, x, y, params = {}, ports) => ({
|
||||
id,
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
params,
|
||||
...(ports ? { ports } : {}),
|
||||
});
|
||||
const edge = (id, from, fromPort, to, toPort) => ({
|
||||
id,
|
||||
from: { node: from, port: fromPort },
|
||||
to: { node: to, port: toPort },
|
||||
});
|
||||
const fields = [
|
||||
{ key: 'Name', kind: 'text' },
|
||||
{ key: 'Amount', kind: 'number' },
|
||||
];
|
||||
// `loots` = two structs, `mixed` = a struct and a number, `bags` = structs of two shapes, `drops` =
|
||||
// one item of a type with an asset field. The graph adds an item of `loots`, a packed Loot and a
|
||||
// number to `loots`, each through a typed Add item, then tries the number in a typed Set item.
|
||||
const graph = {
|
||||
nodes: [
|
||||
node('tl55-start', 'OnStart', -400, 0),
|
||||
node('tl55-vars', 'Variables', -400, 160, {}, [{ key: 'loots', kind: 'list' }]),
|
||||
node('tl55-at', 'ItemAt', -200, 160, { index: 0 }),
|
||||
node('tl55-add1', 'AddItem', 0, 160, { structType: LOOT }),
|
||||
node('tl55-pack', 'PackStruct', -200, 320, { structType: LOOT, Name: 'Bow', Amount: 2 }, fields),
|
||||
node('tl55-add2', 'AddItem', 200, 160, { structType: LOOT }),
|
||||
node('tl55-num', 'Number', 0, 480, { value: 9 }),
|
||||
node('tl55-add3', 'AddItem', 400, 160, { structType: LOOT }),
|
||||
node('tl55-set', 'SetItem', 600, 160, { structType: LOOT, index: 0 }),
|
||||
node('tl55-count', 'Count', 800, 160),
|
||||
node('tl55-log', 'Log', 1000, 0),
|
||||
],
|
||||
edges: [
|
||||
edge('tl55-e1', 'tl55-start', 'exec', 'tl55-log', 'exec'),
|
||||
edge('tl55-e2', 'tl55-vars', 'loots', 'tl55-at', 'list'),
|
||||
edge('tl55-e3', 'tl55-vars', 'loots', 'tl55-add1', 'list'),
|
||||
edge('tl55-e4', 'tl55-at', 'item', 'tl55-add1', 'value'),
|
||||
edge('tl55-e5', 'tl55-add1', 'result', 'tl55-add2', 'list'),
|
||||
edge('tl55-e6', 'tl55-pack', 'value', 'tl55-add2', 'value'),
|
||||
edge('tl55-e7', 'tl55-add2', 'result', 'tl55-add3', 'list'),
|
||||
edge('tl55-e8', 'tl55-num', 'value', 'tl55-add3', 'value'),
|
||||
edge('tl55-e9', 'tl55-add3', 'result', 'tl55-set', 'list'),
|
||||
edge('tl55-e11', 'tl55-num', 'value', 'tl55-set', 'value'),
|
||||
edge('tl55-e12', 'tl55-set', 'result', 'tl55-count', 'list'),
|
||||
edge('tl55-e10', 'tl55-count', 'count', 'tl55-log', 'message'),
|
||||
],
|
||||
};
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
p.assets.push({
|
||||
id: '${LOOT}',
|
||||
type: 'struct',
|
||||
name: 'Loot 55',
|
||||
props: { Name: 'Coin', Amount: 1 },
|
||||
variables: [
|
||||
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
|
||||
{ key: 'Amount', label: 'Amount', kind: 'number', default: 0 },
|
||||
],
|
||||
});
|
||||
p.assets.push({
|
||||
id: '${DROP}',
|
||||
type: 'struct',
|
||||
name: 'Drop 55',
|
||||
props: { Name: '', Drop: '' },
|
||||
variables: [
|
||||
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
|
||||
{ key: 'Drop', label: 'Drop', kind: 'asset', default: '' },
|
||||
],
|
||||
});
|
||||
p.assets.push({
|
||||
id: 'tl55-chest',
|
||||
type: 'behavior',
|
||||
name: 'Chest 55',
|
||||
props: { enabled: true, runInEditor: false },
|
||||
variables: [
|
||||
{ key: 'loots', label: 'loots', kind: 'list', default: [{ Name: 'Gem', Amount: 4 }, { Name: 'Axe' }] },
|
||||
{ key: 'mixed', label: 'mixed', kind: 'list', default: [{ Name: 'Own' }, 3] },
|
||||
{ key: 'bags', label: 'bags', kind: 'list', default: [{ Gold: 5 }, { Gold: 2, Gems: 1 }] },
|
||||
{
|
||||
key: 'drops',
|
||||
label: 'drops',
|
||||
kind: 'list',
|
||||
itemType: '${DROP}',
|
||||
default: [{ Name: 'Gold', Drop: '', [${JSON.stringify('$struct')}]: '${DROP}' }],
|
||||
},
|
||||
],
|
||||
graph: ${JSON.stringify(graph)},
|
||||
});
|
||||
${MAKE_OBJECT}
|
||||
scene.objects.push(make('tl55-obj', 'tl55-chest', '', 400, 'Chest 55'));
|
||||
`);
|
||||
|
||||
// 55a. The Items row under a list names its type: Mixed, until Loot 55 is picked – then every item
|
||||
// is a Loot, keeping what fits and taking the type's defaults for the rest.
|
||||
await select({ kind: 'asset', assetId: 'tl55-chest' });
|
||||
await sleep(200);
|
||||
const rows = await all('Mixed');
|
||||
check('each list variable has an Items row reading "Mixed"', rows.length === 3, JSON.stringify(rows));
|
||||
await press_(rows[0], 'Items of loots');
|
||||
await pick('Loot 55');
|
||||
const typed = { itemType: (await variable('loots')).itemType, items: await value('loots') };
|
||||
check(
|
||||
'picking Loot 55 types loots: Gem keeps its 4, Axe takes the default Amount 1, both marked',
|
||||
JSON.stringify(typed) ===
|
||||
JSON.stringify({
|
||||
itemType: LOOT,
|
||||
items: [
|
||||
{ Name: 'Gem', Amount: 4, [MARK]: LOOT },
|
||||
{ Name: 'Axe', Amount: 1, [MARK]: LOOT },
|
||||
],
|
||||
}),
|
||||
JSON.stringify(typed),
|
||||
);
|
||||
await shot('01-typed-list');
|
||||
|
||||
// 55b. A list holding a number cannot take a type: the console says why, the list stays mixed.
|
||||
await evaluate('__gm.console.clear()');
|
||||
await press_((await all('Mixed'))[0], 'Items of mixed');
|
||||
await pick('Loot 55');
|
||||
await sleep(300);
|
||||
const lines = ((await json('__gm.console.lines()')) ?? []).map((l) => l.text);
|
||||
const refused = 'mixed holds 1 item that is no struct – only a list of structs can take a type';
|
||||
check(
|
||||
'typing a list with a number in it is refused, in the console, and nothing changes',
|
||||
lines.includes(refused) &&
|
||||
(await variable('mixed')).itemType === undefined &&
|
||||
JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own' }, 3]),
|
||||
JSON.stringify({ lines, mixed: await value('mixed') }),
|
||||
);
|
||||
|
||||
// 55c. + Item on the typed list adds a Loot at its defaults, no kind menu in between. A typed item
|
||||
// has the type's fields, fixed; the mixed list's struct item keeps its own.
|
||||
const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(list('loots'))})`);
|
||||
await press_(head?.add, '+ Item of loots');
|
||||
const added = (await value('loots'))[2];
|
||||
check(
|
||||
'+ Item on a typed list adds a Loot at its defaults straight away',
|
||||
JSON.stringify(added) === JSON.stringify({ Name: 'Coin', Amount: 1, [MARK]: LOOT }) &&
|
||||
((await json('__gm.menuRows()')) ?? []).length === 0,
|
||||
JSON.stringify(added),
|
||||
);
|
||||
const drawn = { loots: await locked('loots'), mixed: await locked('mixed') };
|
||||
check(
|
||||
'the typed items are drawn with fixed fields, the struct item of the mixed list with its own',
|
||||
JSON.stringify(drawn) === JSON.stringify({ loots: [true, true, true], mixed: [false, false] }),
|
||||
JSON.stringify(drawn),
|
||||
);
|
||||
|
||||
// 55d. The struct item of the mixed list picks a type of its own; the number beside it stays.
|
||||
await press_(
|
||||
await json(`__gm.values.typeAt('inspector', ${JSON.stringify(list('mixed'))}, 0)`),
|
||||
'Type of mixed[0]',
|
||||
);
|
||||
await pick('Loot 55');
|
||||
check(
|
||||
'the struct item in the mixed list becomes a Loot with fixed fields, the number beside it stays',
|
||||
JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own', Amount: 1, [MARK]: LOOT }, 3]) &&
|
||||
JSON.stringify(await locked('mixed')) === JSON.stringify([true, false]),
|
||||
JSON.stringify(await value('mixed')),
|
||||
);
|
||||
await shot('02-item-type');
|
||||
|
||||
// 55e. A field renamed on the asset reaches the typed list and the typed item, values kept.
|
||||
await select({ kind: 'asset', assetId: LOOT });
|
||||
await sleep(200);
|
||||
const amount = (await all('Amount')).sort((a, b) => b.x - a.x)[0];
|
||||
await press_(amount, 'the Amount name');
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('Count');
|
||||
await press('Enter');
|
||||
await sleep(200);
|
||||
const renamed = { loots: await value('loots'), mixed: (await value('mixed'))[0] };
|
||||
check(
|
||||
'renaming Amount to Count reaches every Loot item, typed list and mixed list alike',
|
||||
JSON.stringify(renamed) ===
|
||||
JSON.stringify({
|
||||
loots: [
|
||||
{ Name: 'Gem', Count: 4, [MARK]: LOOT },
|
||||
{ Name: 'Axe', Count: 1, [MARK]: LOOT },
|
||||
{ Name: 'Coin', Count: 1, [MARK]: LOOT },
|
||||
],
|
||||
mixed: { Name: 'Own', Count: 1, [MARK]: LOOT },
|
||||
}),
|
||||
JSON.stringify(renamed),
|
||||
);
|
||||
await undo();
|
||||
check(
|
||||
'one undo takes it back in both',
|
||||
JSON.stringify((await value('loots'))[0]) === JSON.stringify({ Name: 'Gem', Amount: 4, [MARK]: LOOT }) &&
|
||||
JSON.stringify((await value('mixed'))[0]) === JSON.stringify({ Name: 'Own', Amount: 1, [MARK]: LOOT }),
|
||||
JSON.stringify({ loots: await value('loots'), mixed: await value('mixed') }),
|
||||
);
|
||||
|
||||
// 55f. Make struct type on a list of two shapes: every field either has, at its first value.
|
||||
await select({ kind: 'asset', assetId: 'tl55-chest' });
|
||||
await sleep(200);
|
||||
await press_((await all('Mixed')).at(-1), 'Items of bags');
|
||||
await pick('Make struct type');
|
||||
const bags = await variable('bags');
|
||||
const made = await asset(bags.itemType ?? '');
|
||||
check(
|
||||
'Make struct type makes "bags" of Gold and Gems and types the list with it',
|
||||
made?.type === 'struct' &&
|
||||
made.name === 'bags' &&
|
||||
JSON.stringify(made.props) === '{"Gold":5,"Gems":1}' &&
|
||||
JSON.stringify(await value('bags')) ===
|
||||
JSON.stringify([
|
||||
{ Gold: 5, Gems: 1, [MARK]: made.id },
|
||||
{ Gold: 2, Gems: 1, [MARK]: made.id },
|
||||
]),
|
||||
JSON.stringify({ bags, made, value: await value('bags') }),
|
||||
);
|
||||
await shot('03-make-type');
|
||||
|
||||
// 55h. An item keeps an asset id as a plain text – its type says it is an asset: drawn with the
|
||||
// asset mark and picked from the asset menu, the id stays a plain text in the item.
|
||||
const drops = list('drops');
|
||||
const kindsDrawn = ((await json("__gm.values.lists('inspector')")) ?? []).find(
|
||||
(l) => l.title === drops,
|
||||
)?.fieldKinds;
|
||||
check(
|
||||
'the fields of a typed item are drawn as the kinds its type declares: Name a text, Drop an asset',
|
||||
JSON.stringify(kindsDrawn) === JSON.stringify([{ Name: 'text', Drop: 'asset' }]),
|
||||
JSON.stringify(kindsDrawn),
|
||||
);
|
||||
const dropField = await inView(
|
||||
t,
|
||||
'__gm.inspectorRect()',
|
||||
`__gm.values.fieldAt('inspector', ${JSON.stringify(drops)}, 0, 'Drop')`,
|
||||
);
|
||||
await press_(dropField, 'the Drop field of drops[0]');
|
||||
const offered = ((await json('__gm.menuRows()')) ?? []).filter((row) => row && row !== 'None');
|
||||
if (offered[0]) await pick(offered[0]);
|
||||
const dropped = (await value('drops'))[0];
|
||||
const target = await asset(typeof dropped?.Drop === 'string' ? dropped.Drop : '');
|
||||
check(
|
||||
'clicking the Drop field opens the asset menu, and the picked asset lands as its id',
|
||||
offered.length > 0 && target !== null && offered[0].startsWith(target.name) && dropped.Name === 'Gold',
|
||||
JSON.stringify({ offered: offered.slice(0, 3), dropped }),
|
||||
);
|
||||
await shot('04-asset-field');
|
||||
|
||||
// 55g. In a run the items arrive without the mark: Add item typed Loot 55 takes item 0 of loots
|
||||
// and the packed Bow, and turns the 9 away with a line in the console – so does Set item.
|
||||
const logFrom = t.consoleLog.length;
|
||||
await t.play();
|
||||
await sleep(600);
|
||||
await stop();
|
||||
const logged = t.consoleLog
|
||||
.slice(logFrom)
|
||||
.filter((line) => line.startsWith('[behavior Chest 55]'))
|
||||
.map((line) => line.split(']').pop().trim());
|
||||
check(
|
||||
'a typed Add item takes a Loot of the list and a packed one, and reports the 9 without adding it',
|
||||
logged.includes('Add item: 9 is no Loot 55 – not added') && logged.at(-1) === '5',
|
||||
JSON.stringify(logged),
|
||||
);
|
||||
check(
|
||||
'a typed Set item reports the 9 too and leaves the item it would replace',
|
||||
JSON.stringify(logged) ===
|
||||
JSON.stringify(['Add item: 9 is no Loot 55 – not added', 'Set item: 9 is no Loot 55 – not set', '5']),
|
||||
JSON.stringify(logged),
|
||||
);
|
||||
|
||||
// Clean up.
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj');
|
||||
const made = ${JSON.stringify(made?.id ?? '')};
|
||||
p.assets = p.assets.filter(
|
||||
(a) => a.id !== 'tl55-chest' && a.id !== '${LOOT}' && a.id !== '${DROP}' && a.id !== made,
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { clipboard } from './51-clipboard.mjs';
|
||||
import { hierarchyGroups } from './52-hierarchy-groups.mjs';
|
||||
import { curves } from './53-curves.mjs';
|
||||
import { structTypes } from './54-struct-types.mjs';
|
||||
import { typedLists } from './55-typed-lists.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -113,4 +114,5 @@ export const STEPS = [
|
||||
hierarchyGroups,
|
||||
curves,
|
||||
structTypes,
|
||||
typedLists,
|
||||
];
|
||||
Reference in new issue
Block a user