diff --git a/README.md b/README.md index 1e3583e8..8d752acd 100644 --- a/README.md +++ b/README.md @@ -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, 1407 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1416 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 diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index bb521dee..94936e18 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -17,6 +17,7 @@ import { type StructFieldDef, variableDef, } from '../properties/PropertySchema'; +import { valueAt, withValueAt } from '../properties/valuePath'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError, RenameCommand } from './Command'; import { renameVariablePorts } from './portCommands'; @@ -189,7 +190,10 @@ export class SetVariableKeptCommand implements Command { const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _'; -/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */ +/** + * Where declared fields live: on a struct variable, or on a struct field of one, however deep + * (#239). A list item carries its own shape instead. + */ interface FieldOwner { fields(): readonly StructFieldDef[]; setFields(next: readonly StructFieldDef[]): void; @@ -199,15 +203,50 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef return requireVariableOwner(project, assetId).variables.find((v) => v.key === key); } -function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner { +/** `path` names the struct fields from the variable down to the struct whose fields are meant. */ +function requireFieldOwner( + project: Project, + assetId: string, + key: string, + path: readonly string[], +): FieldOwner { const def = variableOf(project, assetId, key); if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`); - return { fields: () => def.fields, setFields: (next) => (def.fields = next) }; + if (fieldsAt(def.fields, path) === undefined) + throw new CommandError(`Field "${path.join('.')}" has no fields`); + return { + fields: () => fieldsAt(def.fields, path) ?? [], + setFields: (next) => (def.fields = withFieldsAt(def.fields, path, next)), + }; +} + +function fieldsAt( + fields: readonly StructFieldDef[], + path: readonly string[], +): readonly StructFieldDef[] | undefined { + if (path.length === 0) return fields; + const field = fields.find((f) => f.key === path[0]); + return field?.kind === 'struct' ? fieldsAt(field.fields, path.slice(1)) : undefined; +} + +/** The declaration with the fields at `path` replaced, rebuilt on the way down. */ +function withFieldsAt( + fields: readonly StructFieldDef[], + path: readonly string[], + next: readonly StructFieldDef[], +): readonly StructFieldDef[] { + if (path.length === 0) return next; + return fields.map((f) => + f.key === path[0] && f.kind === 'struct' + ? { ...f, fields: withFieldsAt(f.fields, path.slice(1), next) } + : f, + ); } /** * One struct-carrying value of a variable (its declared default, the behavior's own, an override); a * field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo. + * With a `path` the site is the struct that deep inside the value. */ interface StructSite { id: string; @@ -215,15 +254,19 @@ interface StructSite { write(value: StructValue): void; } -function structSites(project: Project, assetId: string, key: string): StructSite[] { +function structSites(project: Project, assetId: string, key: string, path: readonly string[]): StructSite[] { + const structIn = (value: PropertyValue | undefined): StructValue => { + const at = valueAt(value, path); + return isStruct(at) ? at : {}; + }; return ( valueHolders(project, assetId, key) // An override the object does not have stays absent: writing one here would invent a deviation. .filter((h) => !h.optional || isStruct(h.read())) .map((holder) => ({ id: holder.id, - read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}), - write: (value: StructValue) => holder.write(value), + read: () => structIn(holder.read()), + write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value), })) ); } @@ -276,28 +319,30 @@ function renameField(value: StructValue, from: string, to: string): StructValue export class AddStructFieldCommand implements Command { readonly label: string; + /** `path` names the struct the field is added to: none for the variable itself. */ constructor( private readonly assetId: string, private readonly key: string, private readonly field: StructFieldDef, + private readonly path: readonly string[] = [], ) { this.label = `Add field ${field.key}`; } execute(project: Project): void { - const owner = requireFieldOwner(project, this.assetId, this.key); + const owner = requireFieldOwner(project, this.assetId, this.key, this.path); if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD); if (owner.fields().some((f) => f.key === this.field.key)) throw new CommandError(`Field "${this.field.key}" already exists`); owner.setFields([...owner.fields(), { ...this.field }]); - for (const site of structSites(project, this.assetId, this.key)) + for (const site of structSites(project, this.assetId, this.key, this.path)) site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) }); } undo(project: Project): void { - const owner = requireFieldOwner(project, this.assetId, this.key); + const owner = requireFieldOwner(project, this.assetId, this.key, this.path); owner.setFields(owner.fields().filter((f) => f.key !== this.field.key)); - for (const site of structSites(project, this.assetId, this.key)) + for (const site of structSites(project, this.assetId, this.key, this.path)) site.write(withoutField(site.read(), this.field.key)); } } @@ -307,21 +352,23 @@ export class RemoveStructFieldCommand implements Command { readonly label: string; private removed?: { field: Removed; values: Map }; + /** `path` names the struct the field is taken from: none for the variable itself. */ constructor( private readonly assetId: string, private readonly key: string, private readonly field: string, + private readonly path: readonly string[] = [], ) { this.label = `Remove field ${field}`; } execute(project: Project): void { - const owner = requireFieldOwner(project, this.assetId, this.key); + const owner = requireFieldOwner(project, this.assetId, this.key, this.path); const fields = [...owner.fields()]; const field = removeFirst(fields, (f) => f.key === this.field); if (!field) throw new CommandError(`Field "${this.field}" not found`); const values = new Map(); - for (const site of structSites(project, this.assetId, this.key)) { + for (const site of structSites(project, this.assetId, this.key, this.path)) { const value = site.read(); const item = value[this.field]; if (item !== undefined) values.set(site.id, item); @@ -333,11 +380,11 @@ export class RemoveStructFieldCommand implements Command { undo(project: Project): void { if (!this.removed) return; - const owner = requireFieldOwner(project, this.assetId, this.key); + const owner = requireFieldOwner(project, this.assetId, this.key, this.path); const fields = [...owner.fields()]; restoreAt(fields, [this.removed.field]); owner.setFields(fields); - for (const site of structSites(project, this.assetId, this.key)) { + for (const site of structSites(project, this.assetId, this.key, this.path)) { const value = this.removed.values.get(site.id); if (value !== undefined) site.write({ ...site.read(), [this.field]: value }); } @@ -345,24 +392,26 @@ export class RemoveStructFieldCommand implements Command { } export class RenameStructFieldCommand extends RenameCommand { + /** `path` names the struct the field belongs to: none for the variable itself. */ constructor( private readonly assetId: string, private readonly key: string, from: string, to: string, + private readonly path: readonly string[] = [], ) { super(`Rename field ${from}`, from, to); } protected rename(project: Project, from: string, to: string): void { - const owner = requireFieldOwner(project, this.assetId, this.key); + const owner = requireFieldOwner(project, this.assetId, this.key, this.path); if (from === to) return; if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD); const fields = owner.fields(); if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`); if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`); owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f))); - for (const site of structSites(project, this.assetId, this.key)) + for (const site of structSites(project, this.assetId, this.key, this.path)) site.write(renameField(site.read(), from, to)); } } diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index 115faad4..0436b0e7 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -1,6 +1,7 @@ -import { itemLike } from '../../../properties/listItems'; +import type { PropertyValue } from '../../../model/Properties'; +import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems'; import type { NodeDef } from '../../NodeRegistry'; -import { asList, asNumber, asValueItem, isEntity } from '../../values'; +import { asList, asNumber, asValueItem, isEntity, type ValueList } from '../../values'; import { any, int, list } from '../shared'; export const SetItem: NodeDef = { @@ -20,8 +21,21 @@ export const SetItem: NodeDef = { const value = asValueItem(inputs.value); const slot = items[i]; // An object has no kind to keep and none to take on, so it is written as it is (#157). - next[i] = isEntity(value) || isEntity(slot) ? value : itemLike(slot, value); + if (isEntity(value) || isEntity(slot)) next[i] = value; + else next[i] = nestedKind(slot) ? sameNested(slot, value) : itemLike(slot, value); return { outputs: { result: next } }; }, params: [], }; + +/** Whether a slot holds a list or a struct, which may carry objects `itemLike` would not keep. */ +const nestedKind = (slot: ValueList[number] | undefined): boolean => { + const kind = itemKindOf(slot as PropertyValue); + return kind === 'list' || kind === 'struct'; +}; + +/** A list or struct slot takes a value of its own kind as it is, anything else as its empty one. */ +function sameNested(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] { + const kind = itemKindOf(slot as PropertyValue); + return itemKindOf(value as PropertyValue) === kind ? value : makeItem(kind, undefined); +} diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index d1097762..4081d85f 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -16,9 +16,10 @@ import { ENTITY_HANDLE, type EntityHandle } from './NodeContext'; /** * A list travelling along a data edge. Unlike a stored one it may hold live object handles, which is - * what lets `Raycast all` hand out the objects it met (#157). + * what lets `Raycast all` hand out the objects it met (#157). Items nest like stored ones (#239); an + * object deeper down is held the way a bag holds one (`heldObject`). */ -export type ValueList = readonly (number | string | boolean | StructValue | EntityHandle)[]; +export type ValueList = readonly (PropertyValue | EntityHandle)[]; /** A value travelling along a data edge. Objects are live entity handles (play mode only). */ export type Value = PropertyValue | EntityHandle | ValueList | null; @@ -87,9 +88,9 @@ export function unplaced(value: Value | undefined): Value | undefined { return tagOf(value) === 'object' ? null : value; } -/** A list as a run's bag holds it: an object stays the object. */ +/** A list as a run's bag holds it: an object stays the object, however deep it sits. */ function heldList(items: ValueList): ListValue { - return items.map((item) => (isEntity(item) ? heldObject(item) : asListItem(item))); + return items.map(asField); } /** `value` with every object in it named instead – how a value reads as text, however deep it sits. */ @@ -105,24 +106,28 @@ export function namedValue(value: Value | undefined): PropertyValue | undefined return value; } -/** A value narrowed to what a *travelling* list may hold: unlike `asListItem`, a handle stays one. */ +/** A value narrowed to what a *travelling* list may hold: unlike `asField`, a handle stays one. */ export function asValueItem(value: Value | undefined): ValueList[number] { - return isEntity(value) ? value : asListItem(value); -} - -/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */ -function asListItem(value: Value | undefined): number | string | boolean | StructValue { - if (isStructValue(value)) return value; - return asPrimitive(value); + return isEntity(value) ? value : asField(value); } /** - * A value narrowed to what a struct field may hold: a list stays a list and an object the object, - * anything deeper flattens. + * A value as a struct field or list item holds it in a run's bag: lists and structs stay what they + * are at any depth (#239), an object stays the object, a missing value becomes 0. */ export function asField(value: Value | undefined): FieldValue { if (isEntity(value)) return heldObject(value); - return isValueList(value) ? heldList(value) : asPrimitive(value); + if (isValueList(value)) return heldList(value); + if (isStructValue(value)) return heldStruct(value); + return asPrimitive(value); +} + +/** A struct as a run's bag holds it: each field as `asField` says, a tagged item as itself. */ +function heldStruct(value: StructValue): StructValue { + if (tagOf(value) !== undefined) return value; + const fields: Record = {}; + for (const [key, field] of Object.entries(value)) fields[key] = asField(field); + return fields; } function asPrimitive(value: Value | undefined): number | string | boolean { @@ -185,7 +190,7 @@ function mergeStruct(current: StructValue, value: Value | undefined): StructValu const merged: Record = {}; for (const [field, own] of Object.entries(current)) merged[field] = field in incoming ? asTypeOf(own, incoming[field]) : own; - return merged as StructValue; + return merged; } /** `value` converted to the type the slot already holds, so a number stays a number. */ diff --git a/src/core/graph/variablePorts.ts b/src/core/graph/variablePorts.ts index 403fcfd4..df842ffe 100644 --- a/src/core/graph/variablePorts.ts +++ b/src/core/graph/variablePorts.ts @@ -1,6 +1,7 @@ import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds'; import { itemKindOf, structItemFields } from '../properties/listItems'; import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema'; +import { isStruct, type PropertyValue } from '../model/Properties'; import type { DeclaredPort, GraphNode } from './Graph'; /** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */ @@ -19,7 +20,16 @@ export function structShapeOf(def: PropertyDef): readonly StructFieldDef[] | und if (def.kind === 'struct') return def.fields; if (def.kind !== 'list') return undefined; const item = def.default.find((entry) => itemKindOf(entry) === 'struct'); - return item === undefined ? undefined : structItemFields(item); + return item === undefined ? undefined : itemShape(item); +} + +/** The fields a struct item carries as a declaration would name them, nested ones included. */ +function itemShape(item: PropertyValue | undefined): StructFieldDef[] { + return structItemFields(item).map(({ key, kind }) => + kind === 'struct' + ? { key, kind, fields: itemShape(isStruct(item) ? item[key] : undefined) } + : { key, kind }, + ); } /** diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts index 58583b51..f617f9ed 100644 --- a/src/core/model/Properties.ts +++ b/src/core/model/Properties.ts @@ -1,16 +1,16 @@ import type { TaggedKind, ValueOf } from '../properties/kinds'; -/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */ -export type ListValue = readonly (number | string | boolean | StructValue)[]; +/** Lists and structs nest to any depth (#239): an item may be a list or a struct itself. */ +export type ListValue = readonly PropertyValue[]; -/** What a struct field may hold. A field is never itself a struct – except a tagged item. */ -export type FieldValue = number | string | boolean | ListValue | TaggedItem; +/** What a struct field or a list item holds – any value, since both nest to any depth. */ +export type FieldValue = PropertyValue; /** Several named values travelling as one, so a bundle needs one port instead of one per part. */ -export type StructValue = { readonly [field: string]: FieldValue }; +export type StructValue = { readonly [field: string]: PropertyValue }; /** Colors are stored as 0xRRGGBB numbers. */ -export type PropertyValue = FieldValue | StructValue; +export type PropertyValue = number | string | boolean | ListValue | StructValue; export type PropertyBag = Record; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 238d37bd..e0f6b3f8 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -47,7 +47,7 @@ import { VECTOR_WIDTH_KEY, } from '../model/vectorart'; import type { SceneObject } from '../model/SceneObject'; -import { flatListOf, listItemOf } from './listItems'; +import { listItemOf, listOf } from './listItems'; import { FONT_KEY } from './fonts'; import { BURST_COUNT_KEY, @@ -194,13 +194,20 @@ export interface PrimitiveFieldDef { kind: FieldKind; } -/** A list held by a struct field. Its items declare nothing either, but they stay flat. */ +/** A list held by a struct field. Its items declare nothing either, and may nest like any list. */ export interface ListFieldDef { key: string; kind: 'list'; } -export type StructFieldDef = PrimitiveFieldDef | ListFieldDef; +/** A struct held by a struct field declares its fields like a struct variable does (#239). */ +export interface StructFieldStructDef { + key: string; + kind: 'struct'; + fields: readonly StructFieldDef[]; +} + +export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef; export type StructDef = Extract; @@ -766,6 +773,7 @@ export function variableDef( /** The empty value of a kind, so a field that is not there yet still has one. */ export function emptyFieldValue(field: StructFieldDef): FieldValue { if (field.kind === 'object') return NO_OBJECT; + if (field.kind === 'struct') return structOf(field.fields, undefined); return field.kind === 'list' ? [] : emptyOf(field.kind); } @@ -806,8 +814,12 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal } } -/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */ +/** + * A struct field as its declaration promises: a list field takes only a list (see `ListFieldDef`), + * a struct field is brought into its declared shape, however deep it sits. + */ export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue { if (field.kind === 'object') return NO_OBJECT; - return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind); + if (field.kind === 'struct') return structOf(field.fields, value); + return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind); } diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index 93a99534..609361e4 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -199,10 +199,11 @@ export type TaggedKind = With<'tagged'>; export type ReferenceKind = With<'reference'>; /** The kinds whose value is one primitive – the ones with an empty value. */ export type FlatKind = Extract['kind']; -/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */ -export type ListItemKind = FieldKind | 'struct'; -/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */ -export type StructFieldKind = FieldKind | 'list'; +/** + * What a list item or a struct field may be: a primitive, or a list or struct again. Nothing bounds + * the depth (#239), so the two places offer the same kinds. + */ +export type NestedKind = FieldKind | 'list' | 'struct'; type Widen = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never; export type ValueOf = Widen['empty']>; @@ -221,8 +222,7 @@ const kindsWith = (role: Role): readonly With[] => export const VARIABLE_KINDS = kindsWith('variable'); export const FIELD_KINDS = kindsWith('field'); export const DECLARED_PORT_KINDS = kindsWith('port'); -export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct']; -export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list']; +export const NESTED_KINDS: readonly NestedKind[] = [...FIELD_KINDS, 'list', 'struct']; export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind); diff --git a/src/core/properties/listItems.ts b/src/core/properties/listItems.ts index 5bef7888..067f9071 100644 --- a/src/core/properties/listItems.ts +++ b/src/core/properties/listItems.ts @@ -1,54 +1,42 @@ /** * A list declares nothing about what it holds: every item carries its own kind, read back off the - * value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself - * (`tagOf`), which keeps every list ever stored readable. + * value. JSON says what a number, a text, a boolean, a list and a struct are; a tagged kind says so + * itself (`tagOf`), which keeps every list ever stored readable. * - * Depth is capped: a struct item holds flat items, and so does a list inside a struct field. + * Items nest to any depth (#239): the items of a list item and the fields of a struct item go + * through the same coercion again, so nothing deeper is dropped. */ import { - type FieldValue, isList, isStruct, + type ListValue, type PropertyValue, type StructValue, - type TaggedItem, tagged, tagOf, untag, } from '../model/Properties'; -import { - coerceField, - type FieldKind, - isKindWith, - isTaggedKind, - type ListItemKind, - plainKindOf, -} from './kinds'; +import { coerceField, isKindWith, isTaggedKind, type NestedKind, plainKindOf } from './kinds'; /** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */ -export type ListItem = number | string | boolean | StructValue; - -/** What a flat position holds: no struct except a tagged item, so the depth stays capped. */ -export type FlatItem = number | string | boolean | TaggedItem; +export type ListItem = PropertyValue; /** The kind an item is, taken from the item itself – there is nowhere else it could come from. */ -export function itemKindOf(item: PropertyValue | undefined): ListItemKind { +export function itemKindOf(item: PropertyValue | undefined): NestedKind { const tag = tagOf(item); if (tag !== undefined && isTaggedKind(tag)) return tag; - if (isStruct(item) || isList(item)) return 'struct'; + if (isList(item)) return 'list'; + if (isStruct(item)) return 'struct'; const plain = plainKindOf(typeof item); return plain !== undefined && isKindWith('field', plain) ? plain : 'number'; } -/** Flat positions hold no struct, so one reads as its text. */ -export function fieldKindOf(value: PropertyValue | undefined): FieldKind { - const kind = itemKindOf(value); - return kind === 'struct' ? 'text' : kind; -} - /** `value` as an item of `kind`, which is how a row changes what it holds. */ -export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem { - return kind === 'struct' ? structItemOf(value) : makeFlatItem(kind, value); +export function makeItem(kind: NestedKind, value: PropertyValue | undefined): ListItem { + if (kind === 'list') return listOf(value); + if (kind === 'struct') return structItemOf(value); + if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind)); + return coerceField(untag(value), kind); } /** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */ @@ -56,50 +44,26 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu return makeItem(itemKindOf(current), value); } -/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */ -export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem { - if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind)); - return coerceField(untag(value), kind); -} - -/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */ -export function fieldLike(current: PropertyValue | undefined, value: PropertyValue | undefined): FlatItem { - return makeFlatItem(fieldKindOf(current), value); -} - /** Any value as a valid item; the one coercion a list value goes through. */ export function listItemOf(value: PropertyValue | undefined): ListItem { return makeItem(itemKindOf(value), value); } -/** - * A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than - * flattened – the bound the store writes. - */ -export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined { - if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined; - return makeFlatItem(fieldKindOf(value), value); +/** The items of a list, each as the kind its own value says; anything else holds none. */ +export function listOf(value: PropertyValue | undefined): ListValue { + return isList(value) ? value.map(listItemOf) : []; } /** A struct item keeps the fields it carries, each as the kind its own value says. */ export function structItemOf(value: PropertyValue | undefined): StructValue { if (!isStruct(value) || tagOf(value) !== undefined) return {}; - const fields: Record = {}; - for (const [key, item] of Object.entries(value)) { - const flat = flatItemOf(item); - if (flat !== undefined) fields[key] = flat; - } + const fields: Record = {}; + for (const [key, item] of Object.entries(value)) fields[key] = listItemOf(item); return fields; } -/** The items of a list sitting in a struct field: flat ones only. */ -export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[] { - if (!isList(value)) return []; - return value.map(flatItemOf).filter((item): item is FlatItem => item !== undefined); -} - /** 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: FieldKind }[] { +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: fieldKindOf(value) })); + return Object.entries(item).map(([key, value]) => ({ key, kind: itemKindOf(value) })); } diff --git a/src/core/properties/valuePath.ts b/src/core/properties/valuePath.ts new file mode 100644 index 00000000..49f158ff --- /dev/null +++ b/src/core/properties/valuePath.ts @@ -0,0 +1,49 @@ +/** + * Where a value sits inside a nested one (#239): a key per struct step, an index per list step. A + * list in a struct field in a list item is `[3, 'drops']` – the address every nested row edits by. + */ +import { isList, isStruct, type PropertyValue, tagOf } from '../model/Properties'; + +export type ValueStep = string | number; +export type ValuePath = readonly ValueStep[]; + +/** The value at `path`, or undefined where a step finds nothing to step into. */ +export function valueAt(value: PropertyValue | undefined, path: ValuePath): PropertyValue | undefined { + let at = value; + for (const step of path) { + if (typeof step === 'number') at = isList(at) ? at[step] : undefined; + else at = isStruct(at) && tagOf(at) === undefined ? at[step] : undefined; + if (at === undefined) return undefined; + } + return at; +} + +/** + * `value` with the part at `path` replaced by `next`, rebuilt along the way so nothing shared is + * changed. A struct step that finds no struct starts an empty one; a list index out of range, or a + * list step that finds no list, leaves `value` as it is – there is no item to replace. + */ +export function withValueAt( + value: PropertyValue | undefined, + path: ValuePath, + next: PropertyValue, +): PropertyValue | undefined { + if (path.length === 0) return next; + const [step, ...rest] = path as [ValueStep, ...ValueStep[]]; + if (typeof step === 'number') { + if (!isList(value) || step < 0 || step >= value.length) return value; + const inner = withValueAt(value[step], rest, next); + if (inner === undefined || (rest.length > 0 && inner === value[step])) return value; + return value.map((item, i) => (i === step ? inner : item)); + } + const struct = isStruct(value) && tagOf(value) === undefined ? value : {}; + const inner = withValueAt(struct[step], rest, next); + // Nothing replaced further down (an index out of range): the value stays the very same. + if (inner === undefined || (rest.length > 0 && inner === struct[step])) return value; + return { ...struct, [step]: inner }; +} + +/** How a path reads in a title: `drops`, `[3]`. */ +export function pathLabel(path: ValuePath): string[] { + return path.map((step) => (typeof step === 'number' ? `[${step}]` : step)); +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 450468d2..2aa18023 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -47,14 +47,7 @@ import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { isSoundFile } from '../model/sound'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; -import { - type FieldValue, - type PropertyBag, - type PropertyValue, - type StructValue, - tagOf, - type Vec2, -} from '../model/Properties'; +import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { readScope, readSharedScope } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; @@ -81,7 +74,7 @@ import { VARIABLE_KINDS, type VariableKind, } from '../properties/kinds'; -import { type FlatItem, makeFlatItem } from '../properties/listItems'; +import { makeItem } from '../properties/listItems'; import { coerceValue, initialOf, @@ -380,6 +373,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] { function readField(key: string, f: Record): 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 === 'struct') return { key, kind: 'struct', fields: normalizeStructFields(f.fields) }; return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null; } @@ -787,46 +781,27 @@ function asRecord(value: unknown): Record { } /** - * A value from an untyped slot (node params, instance edits). Lists and structs are kept but - * flattened one level, so nothing deeper than the model allows can enter behind the type's back. + * A value from an untyped slot (node params, instance edits): lists and structs at any depth, each + * item as the kind it says it is. Only what is no value at all (`null`, a bad tag) is dropped. */ function asStoredValue(value: unknown): PropertyValue | undefined { if (isPrimitive(value)) return value; - if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined); - return asStoredStruct(value, asStoredField); -} - -const isPrimitive = (value: unknown): value is number | string | boolean => - typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean'; - -/** A primitive, or the one object a flat position may hold: a tagged item. */ -const asFlatItem = (value: unknown): FlatItem | undefined => { - if (isPrimitive(value)) return value; + if (Array.isArray(value)) + return value.map(asStoredValue).filter((item): item is PropertyValue => item !== undefined); + if (!value || typeof value !== 'object') return undefined; const tag = tagOf(value); - return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined; -}; - -/** A list item: a flat one, or a struct of flat ones. */ -const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined => - asFlatItem(value) ?? asStoredStruct(value, asFlatItem); - -/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */ -const asStoredField = (value: unknown): FieldValue | undefined => - Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value); - -function asStoredStruct( - value: unknown, - ofField: (item: unknown) => FieldValue | undefined, -): StructValue | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const fields: Record = {}; + if (tag !== undefined) return isTaggedKind(tag) ? makeItem(tag, value as PropertyValue) : undefined; + const fields: Record = {}; for (const [key, item] of Object.entries(value)) { - const stored = ofField(item); + const stored = asStoredValue(item); if (stored !== undefined) fields[key] = stored; } return fields; } +const isPrimitive = (value: unknown): value is number | string | boolean => + typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean'; + function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index c508e805..0da7978e 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -29,7 +29,7 @@ import { import { findAsset, type Project } from '../../core/model/Project'; import type { PropertyBag, PropertyValue } from '../../core/model/Properties'; import { readScope, readSharedScope, type Scope, scopeOf, sharedScopeOf } from '../../core/model/Scope'; -import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds'; +import { type NestedKind, VARIABLE_KINDS } from '../../core/properties/kinds'; import { isRowShown, type PropertyDef, @@ -50,7 +50,9 @@ import { buildVariable, kindMenu, type VariableEditing } from './variableRows'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; import { assetValue } from './valueSpecs'; -const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind }); +/** A struct field declares its own fields, starting with none. */ +const newField = (key: string, kind: NestedKind): StructFieldDef => + kind === 'struct' ? { key, kind, fields: [] } : { key, kind }; /** * Structure-defining facts of an asset selection: the schema keys and kinds of the rows shown, plus @@ -73,9 +75,9 @@ export function assetSignature(project: Project, assetId: string): string { return rows.map(defSignature).join(',') + machine + kept + keeps + shapes; } -export function defSignature(def: PropertyDef): string { - // Only a struct has rows of its own; a list is one row whatever its items are. - const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : ''; +export function defSignature(def: PropertyDef | StructFieldDef): string { + // Only a struct has rows of its own, at any depth; a list is one row whatever its items are. + const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : ''; return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; } @@ -169,7 +171,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): return { ...previewingHandlers(host, spec), ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), - ...structFields(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })), + ...structFields(host, def, spec, (path) => ({ kind: 'asset', assetId, key: def.key, path })), }; } @@ -183,6 +185,14 @@ export function shownValues(host: InspectorHost, asset: Asset): PropertyBag { return live ? { ...values, ...bagWithoutObjects(live) } : values; } +/** The fields declared at `path` inside a struct declaration; none where the path leads nowhere. */ +function fieldsAt(fields: readonly StructFieldDef[], path: readonly string[]): readonly StructFieldDef[] { + const [step, ...rest] = path; + if (step === undefined) return fields; + const field = fields.find((f) => f.key === step); + return field?.kind === 'struct' ? fieldsAt(field.fields, rest) : []; +} + /** Declared variables, each with its declaration editable – plus an "add" menu. */ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAsset): void { const header = new HStack({ gap: Theme.gap, align: 'center' }); @@ -206,14 +216,16 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset | VariablesAss const edit: VariableEditing = { rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)), remove: () => run(new RemoveVariableCommand(asset.id, def.key)), - renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)), - removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)), - addField: (kind) => + renameField: (path, field, next) => + run(new RenameStructFieldCommand(asset.id, def.key, field.key, next, path)), + removeField: (path, field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key, path)), + addField: (path, kind) => run( new AddStructFieldCommand( asset.id, def.key, - newField(nextFieldName(def.kind === 'struct' ? def.fields : []), kind), + newField(nextFieldName(def.kind === 'struct' ? fieldsAt(def.fields, path) : []), kind), + path, ), ), }; diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index def27d6e..1e161f4d 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -21,6 +21,7 @@ import { tilemapLayerNames } from '../../core/model/tilemap'; import { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; import { emptyFieldValue, + structOf, flatDef, type PropertyDef, type StructFieldDef, @@ -71,11 +72,12 @@ export interface FieldHandlers { /** Sends a list to the List tab; only the caller knows where the value it edits lives. */ openList?: () => void; /** - * Handlers for one field of a struct. Writing a field means writing the whole bundle, so only - * the caller – which knows where that bundle lives – can build them. Without them a struct row - * can only show what it holds. + * Handlers for one field of a struct, `path` being the keys from the bundle down to it (a struct + * field nests further, #239). Writing a field means writing the whole bundle, so only the caller – + * which knows where that bundle lives – can build them. Without them a struct row can only show + * what it holds. */ - structFieldHandlers?: (field: StructFieldDef) => FieldHandlers; + structFieldHandlers?: (path: readonly string[], field: StructFieldDef) => FieldHandlers; } export interface FieldRow { @@ -98,6 +100,15 @@ export const NAME_WIDTH = 90; export const FIELD_INDENT = 12; const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH; +/** + * A name column `depth` indent steps in, narrowed by what the indent takes so the value after it + * starts on the same column at any depth. Deep enough, it stops shrinking and the values move right. + */ +export function nameWidth(depth: number): number { + return Math.max(MIN_NAME_WIDTH, NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap)); +} +const MIN_NAME_WIDTH = 40; + /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ export interface FieldAction { label: string; @@ -276,7 +287,7 @@ export function linkLabel( }; } -interface Field { +export interface Field { widget: Widget; /** Whether the widget should take the remaining row width. */ grow: boolean; @@ -298,11 +309,27 @@ export function removeButton(onClick: () => void): Button { export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} }; -/** A struct field as a property definition – a list field becomes the same row a list property has. */ +/** + * A struct field as a property definition – a list field becomes the same row a list property has, + * a struct field the same block a struct property has. + */ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef { - return field.kind === 'list' - ? { key: field.key, label, kind: 'list', default: [] } - : flatDef(field.key, field.kind, { label }); + if (field.kind === 'list') return { key: field.key, label, kind: 'list', default: [] }; + if (field.kind === 'struct') + return { + key: field.key, + label, + kind: 'struct', + fields: field.fields, + default: structOf(field.fields, undefined), + }; + return flatDef(field.key, field.kind, { label }); +} + +/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */ +export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers { + const per = handlers.structFieldHandlers; + return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY; } /** What a reference kind names in the project: how an id reads, and what its picker may offer. */ @@ -439,7 +466,9 @@ export function createField( const fields = new Map(); for (const field of def.fields) { const { row } = labelledRow(field.key); - const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field)); + const own = + field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field); + const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own); addValue(row, sub); fields.set(field.key, sub); stack.add(row); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 8999d599..2f88d5c0 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -5,6 +5,7 @@ import { RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../core/commands/applyCommands'; +import type { ValuePath } from '../../core/properties/valuePath'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; @@ -342,12 +343,12 @@ function propertyRow( owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY) ? tilemapSizeValue(host.state, holderId, objectId, def.key) : overrideValue(host.state, holderId, objectId, def.key); - const fieldSlot = (field: string): ListSlot => ({ + const fieldSlot = (path: ValuePath): ListSlot => ({ kind: 'object', holderId, objectId, key: def.key, - field, + path, }); const rowActions: RowActions = { extra: aspectActions( diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts index 2a05b4e6..39ae1509 100644 --- a/src/editor/inspector/previewHandlers.ts +++ b/src/editor/inspector/previewHandlers.ts @@ -7,6 +7,7 @@ import { } from '../../core/properties/PropertySchema'; import type { Command } from '../../core/commands/Command'; import type { PropertyValue } from '../../core/model/Properties'; +import { type ValuePath, withValueAt } from '../../core/properties/valuePath'; import type { EditorState, ListSlot } from '../state'; import type { FieldHandlers } from './fieldFactory'; @@ -31,29 +32,30 @@ export function structFields( host: { state: EditorState }, def: PropertyDef, spec: PreviewedValue, - fieldSlot?: (field: string) => ListSlot, + fieldSlot?: (path: ValuePath) => ListSlot, ): Pick { return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {}; } -/** `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. */ +/** + * `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. + * `path` leads from the bundle to the field, through struct fields as deep as they nest (#239). + */ export function structFieldsOf( host: { state: EditorState }, def: StructDef, spec: PreviewedValue, - fieldSlot?: (field: string) => ListSlot, -): (field: StructFieldDef) => FieldHandlers { - return (field) => ({ + fieldSlot?: (path: ValuePath) => ListSlot, +): (path: readonly string[], field: StructFieldDef) => FieldHandlers { + return (path, field) => ({ ...previewingHandlers(host, { ...spec, - toModel: (input, target) => ({ - ...structOf(def.fields, (spec.current ?? spec.read)(target)), - [field.key]: coerceFieldValue(input, field), - }), + toModel: (input, target) => { + const bundle = structOf(def.fields, (spec.current ?? spec.read)(target)); + return withValueAt(bundle, path, coerceFieldValue(input, field)) ?? bundle; + }, }), - ...(field.kind === 'list' && fieldSlot - ? { openList: () => host.state.openList(fieldSlot(field.key)) } - : {}), + ...(field.kind === 'list' && fieldSlot ? { openList: () => host.state.openList(fieldSlot(path)) } : {}), }); } diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index 85e490d2..bd838514 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -1,12 +1,6 @@ import type { PropertyValue } from '../../core/model/Properties'; -import { - type Kind, - kindLabel, - kindMark, - STRUCT_FIELD_KINDS, - type StructFieldKind, -} from '../../core/properties/kinds'; -import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema'; +import { type Kind, kindLabel, kindMark, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; +import type { PropertyDef, StructFieldDef } from '../../core/properties/PropertySchema'; import { Button, Dropdown, @@ -29,7 +23,7 @@ import { fieldDef, type FieldRow, MARK_WIDTH, - NAME_WIDTH, + nameWidth, READ_ONLY, removeButton, type RowActions, @@ -43,13 +37,16 @@ export function kindMenu(kinds: readonly K[], pick: (kind: K) return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) })); } -/** Changing a declaration – offered only on the asset that declares it. */ +/** + * Changing a declaration – offered only on the asset that declares it. `path` names the struct a + * field belongs to, from the variable down (#239): none for the variable's own fields. + */ export interface VariableEditing { rename: (next: string) => void; remove: () => void; - renameField: (field: StructFieldDef, next: string) => void; - removeField: (field: StructFieldDef) => void; - addField: (kind: StructFieldKind) => void; + renameField: (path: readonly string[], field: StructFieldDef, next: string) => void; + removeField: (path: readonly string[], field: StructFieldDef) => void; + addField: (path: readonly string[], kind: NestedKind) => void; } /** A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229). */ @@ -82,7 +79,7 @@ export function buildVariable( let setValue: (next: PropertyValue) => void = () => {}; if (def.kind === 'struct') { head.add(new Spacer(), 1); - if (edit) head.add(addFieldMenu(STRUCT_FIELD_KINDS, edit)); + if (edit) head.add(addFieldMenu(edit, [])); } else { setValue = addValue(head, createField(host, def, value, handlers)); } @@ -91,7 +88,10 @@ export function buildVariable( const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); - if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit); + if (def.kind === 'struct') { + if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); + setValue = structFieldRows(host, def.fields, [], value, handlers, edit); + } return { row: head, @@ -115,52 +115,57 @@ function keepButton(keep: VariableKeep): Button { return button; } -/** One row per field, each with its own value; returns the setter that pushes a bundle into them. */ +/** + * One row per field, each with its own value; a field that is a struct again is a head row with its + * fields indented below it (#239). `path` names the struct these fields belong to. Returns the + * setter that pushes a bundle into them. + */ function structFieldRows( host: InspectorHost, - def: StructDef, + fields: readonly StructFieldDef[], + path: readonly string[], value: PropertyValue, handlers: FieldHandlers, edit: VariableEditing | undefined, ): (next: PropertyValue) => void { - if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); const setters = new Map void>(); - for (const field of def.fields) { - const row = fieldRow(field, edit); - const item = createField( - host, - fieldDef(field), - structFieldValue(value, field), - handlers.structFieldHandlers?.(field) ?? READ_ONLY, - ); - setters.set(field.key, addValue(row, item)); - if (edit) row.add(removeButton(() => edit.removeField(field))); + for (const field of fields) { + const row = fieldRow(field, path, edit); + const item = structFieldValue(value, field); + if (field.kind === 'struct') { + row.add(new Spacer(), 1); + if (edit) row.add(addFieldMenu(edit, [...path, field.key])); + if (edit) row.add(removeButton(() => edit.removeField(path, field))); + host.body.add(row); + setters.set(field.key, structFieldRows(host, field.fields, [...path, field.key], item, handlers, edit)); + continue; + } + const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; + setters.set(field.key, addValue(row, createField(host, fieldDef(field), item, own))); + if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); } return (next) => { - for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field)); + for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field)); }; } -function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack { - const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next))); - return gridRow(1, markLabel(field.kind), name); +function fieldRow(field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined): HStack { + const depth = path.length + 1; + const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next))); + return gridRow(depth, markLabel(field.kind), name); } /** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */ function gridRow(depth: number, mark: Widget, name: Widget): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; - if (depth > 0) row.add(new Spacer(FIELD_INDENT)); + if (depth > 0) row.add(new Spacer(depth * (FIELD_INDENT + Theme.gap) - Theme.gap)); row.add(mark); row.add(name); return row; } -function nameWidth(depth: number): number { - return NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap); -} - /** A text field where the name may be changed, else a label whose text sits where the field's would. */ function nameCell(text: string, depth: number, rename: ((next: string) => void) | undefined): Widget { const width = nameWidth(depth); @@ -201,8 +206,9 @@ export function markPicker(kind: string, items: MenuItem[]): Button { return picker; } -function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown { +/** `path` names the struct the field goes into: none for the variable's own fields. */ +function addFieldMenu(edit: VariableEditing, path: readonly string[]): Dropdown { const add = new Dropdown('+ Field', { variant: 'ghost' }); - add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add); + add.onClick = () => new Menu(kindMenu(NESTED_KINDS, (kind) => edit.addField(path, kind))).openBelow(add); return add; } diff --git a/src/editor/list/ListEditor.ts b/src/editor/list/ListEditor.ts index 2b16f3ca..2407e836 100644 --- a/src/editor/list/ListEditor.ts +++ b/src/editor/list/ListEditor.ts @@ -20,27 +20,31 @@ import { } from '../../ui'; import { type FieldValue, + isList, + isStruct, type ListValue, type PropertyValue, textOf, untag, } from '../../core/model/Properties'; import { nextFieldName } from '../../core/commands/naming'; -import { FIELD_KINDS, type FieldKind, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds'; +import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; import { type ListItem, itemKindOf, - makeFlatItem, makeItem, structItemFields, structItemOf, } from '../../core/properties/listItems'; import { flatDef } from '../../core/properties/PropertySchema'; +import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath'; import { addValue, createField, + type Field, type FieldContext, - NAME_WIDTH, + FIELD_INDENT, + nameWidth, READ_ONLY, removeButton, } from '../inspector/fieldFactory'; @@ -103,7 +107,7 @@ export class ListEditor extends VStack { this.addButton = new Dropdown('+ Item', { variant: 'ghost' }); // Built on click, not once: which kinds fit depends on the list the tab is showing. this.addButton.onClick = () => { - const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS; + const kinds = this.access()?.kinds ?? NESTED_KINDS; new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton); }; footer.add(this.addButton); @@ -139,7 +143,7 @@ export class ListEditor extends VStack { } /** For the check scripts: what the tab shows and how its rows read right now. */ - debugInfo(): { title: string; items: string[]; kinds: ListItemKind[]; offers: ListItemKind[] } { + debugInfo(): { title: string; items: string[]; kinds: NestedKind[]; offers: NestedKind[] } { const access = this.access(); if (!access) return { title: '', items: [], kinds: [], offers: [] }; return { @@ -155,17 +159,10 @@ export class ListEditor extends VStack { return slot ? resolveList(this.state, slot) : null; } - /** Everything that decides how many rows there are and of what kind – a struct item's fields included. */ + /** Everything that decides how many rows there are and of what kind – a struct item's fields included, at any depth. */ private signatureOf(access: ListAccess | null): string { if (!access) return ''; - const shape = access.value - .map( - (item) => - `${itemKindOf(item)}${structItemFields(item) - .map((f) => `:${f.key}:${f.kind}`) - .join('')}`, - ) - .join(','); + const shape = access.value.map(shapeOf).join(','); return `${access.title}|${shape}|${access.value.length}`; } @@ -212,24 +209,12 @@ export class ListEditor extends VStack { label.minWidth = INDEX_WIDTH; if (tall) label.minHeight = Theme.rowHeight; row.add(label); - const mark = markPicker( - kind, - // Through `untag`, so a color keeps its number when it becomes one. - kindMenu( - access.kinds.filter((next) => next !== kind), - (next) => this.replaceItem(access, index, makeItem(next, untag(access.value[index]))), - ), - ); + const mark = this.kindPicker(access, [index], kind); if (tall) mark.minHeight = Theme.rowHeight; row.add(mark); - if (tall) this.addStructItem(access, row, mark, item, index); + if (tall) this.addStructItem(access, row, mark, index); else { - const field = createField( - this.ctx, - flatDef(String(index), kind), - untag(item) ?? '', - access.itemHandlers(index), - ); + const field = this.valueField(access, [index], kind, item); addValue(row, field); this.setters.push((next) => field.setValue(untag(next) ?? '')); this.rowWidgets.push(rowWidgets(mark, field.widget)); @@ -241,92 +226,138 @@ export class ListEditor extends VStack { return row; } - /** A struct item carries its own shape, so its fields are named, typed and removed right here. */ - private addStructItem( + /** The mark of the value at `path`, offering the other kinds. Through `untag`, so a color keeps its number when it becomes one. */ + private kindPicker(access: ListAccess, path: ValuePath, kind: NestedKind): Widget { + return markPicker( + kind, + kindMenu( + access.kinds.filter((next) => next !== kind), + (next) => this.replaceAt(access, path, makeItem(next, untag(valueAt(access.value, path)))), + ), + ); + } + + /** The widget of a value that is not a struct: a list is its item count, opening it one level down. */ + private valueField( access: ListAccess, - row: HStack, - mark: Widget, - item: PropertyValue, - index: number, - ): void { - const bundle = structItemOf(item); - const fields = structItemFields(item); - // A field value edit does not rebuild the row, so every button reads the item as it is now. - const now = () => structItemOf(access.value[index]); + path: ValuePath, + kind: NestedKind, + value: PropertyValue | undefined, + ): Field { + const key = String(path.at(-1)); + if (kind === 'list') + return createField(this.ctx, { key, label: key, kind: 'list', default: [] }, value ?? [], { + ...READ_ONLY, + openList: () => access.openList(path), + }); + return createField( + this.ctx, + flatDef(key, kind as FieldKind), + untag(value) ?? '', + access.handlersAt(path), + ); + } + + /** A struct item carries its own shape, so its fields are named, typed and removed right here. */ + private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void { const stack = new VStack({ gap: Theme.gap }); const widgets = rowWidgets(mark, stack); - const setters = new Map void>(); - const handlers = access.itemHandlers(index); - for (const field of fields) { - const line = new HStack({ gap: Theme.gap, align: 'center' }); - line.minHeight = Theme.rowHeight; - const fieldMark = markPicker( - field.kind, - kindMenu( - FIELD_KINDS.filter((next) => next !== field.kind), - (next) => - this.replaceItem(access, index, { - ...now(), - [field.key]: makeFlatItem(next, untag(now()[field.key])), - }), - ), - ); - const name = nameField(field.key, (to) => { - if (to && to !== field.key) this.replaceItem(access, index, renamedField(now(), field.key, to)); - }); - line.add(fieldMark); - line.add(name); - const sub = createField( - this.ctx, - flatDef(field.key, field.kind), - untag(bundle[field.key]) ?? '', - handlers.structFieldHandlers?.(field) ?? READ_ONLY, - ); - setters.set(field.key, addValue(line, sub)); - widgets.parts.set(field.key, sub.widget); - widgets.marks.set(field.key, fieldMark); - widgets.names.set(field.key, name); - line.add(removeButton(() => this.replaceItem(access, index, withoutField(now(), field.key)))); - stack.add(line); - } - const foot = new HStack({ gap: Theme.gap, align: 'center' }); - foot.minHeight = Theme.rowHeight; - foot.add(addFieldMenu((kind) => this.addField(access, index, now(), kind))); - foot.add(new Spacer(), 1); - stack.add(foot); + const leaves: Leaf[] = []; + this.addStructFields(access, stack, [index], 0, widgets, leaves); row.add(stack, 1); this.setters.push((next) => { - const now = structItemOf(next); - for (const field of fields) setters.get(field.key)?.(untag(now[field.key]) ?? ''); + for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? ''); }); this.rowWidgets.push(widgets); } - private addField(access: ListAccess, index: number, bundle: PropertyValue, kind: FieldKind): void { - const fields = structItemFields(bundle); - const key = nextFieldName(fields); - this.replaceItem(access, index, { ...structItemOf(bundle), [key]: makeFlatItem(kind, 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). Check scripts find a nested field by its keys + * joined with dots (`loot.count`). + */ + private addStructFields( + access: ListAccess, + stack: VStack, + path: ValuePath, + depth: number, + widgets: RowWidgets, + leaves: Leaf[], + ): void { + // 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))) { + 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)); + }); + line.add(fieldMark); + line.add(name); + if (field.kind === 'struct') line.add(new Spacer(), 1); + else { + const value = valueAt(access.value, fieldPath); + const sub = this.valueField(access, fieldPath, field.kind, value); + leaves.push({ within: fieldPath.slice(1), set: addValue(line, sub) }); + widgets.parts.set(id, sub.widget); + } + widgets.marks.set(id, fieldMark); + widgets.names.set(id, name); + line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key)))); + stack.add(line); + if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves); + } + const foot = indentedLine(depth); + foot.add(addFieldMenu((kind) => this.addField(access, path, kind))); + foot.add(new Spacer(), 1); + stack.add(foot); + } + + private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void { + const bundle = valueAt(access.value, path); + const key = nextFieldName(structItemFields(bundle)); + this.replaceAt(access, path, { ...structItemOf(bundle), [key]: makeItem(kind, undefined) }); } /** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */ - private replaceItem(access: ListAccess, index: number, item: ListItem): void { - access.replace(access.value.map((value, i) => (i === index ? item : value))); + private replaceAt(access: ListAccess, path: ValuePath, value: ListItem): void { + const next = withValueAt(access.value, path, value); + if (isList(next)) access.replace(next); } - private addItem(kind: ListItemKind): void { + private addItem(kind: NestedKind): void { const access = this.access(); if (access) access.replace([...access.value, makeItem(kind, undefined)]); } } -/** The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. */ -function nameField(key: string, rename: (next: string) => void): TextField { - return new TextField(key, { width: NAME_WIDTH, onChange: (next) => rename(next.trim()) }); +/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */ +interface Leaf { + within: ValuePath; + set: (value: PropertyValue) => void; } -function addFieldMenu(add: (kind: FieldKind) => void): Dropdown { +/** A line of a struct item, pushed right one step per level it sits below the item. */ +function indentedLine(depth: number): HStack { + const line = new HStack({ gap: Theme.gap, align: 'center' }); + line.minHeight = Theme.rowHeight; + if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap) - Theme.gap)); + return line; +} + +/** + * The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. It + * narrows as it is indented, so the values of every level start on one column. + */ +function nameField(key: string, depth: number, rename: (next: string) => void): TextField { + return new TextField(key, { width: nameWidth(depth), onChange: (next) => rename(next.trim()) }); +} + +function addFieldMenu(add: (kind: NestedKind) => void): Dropdown { const button = new Dropdown('+ Field', { variant: 'ghost' }); - button.onClick = () => new Menu(kindMenu(FIELD_KINDS, add)).openBelow(button); + button.onClick = () => new Menu(kindMenu(NESTED_KINDS, add)).openBelow(button); return button; } @@ -341,3 +372,12 @@ const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value; 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 { + const kind = itemKindOf(item); + if (kind !== 'struct' || !isStruct(item)) return kind; + return `struct(${structItemFields(item) + .map((f) => `${f.key}:${shapeOf(item[f.key])}`) + .join(',')})`; +} diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index c6dc3dd3..c6201ce9 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -1,6 +1,6 @@ /** * Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab - * edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct. + * edit gets the same command, coercion and undo step. A slot with a `path` still writes the whole value. */ import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph'; import { resolveDef } from '../../core/graph/nested'; @@ -8,10 +8,11 @@ import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/Nod import type { Asset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import type { ObjectRef } from '../../core/model/instance'; -import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties'; +import { isList, type ListValue, type PropertyValue } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; -import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds'; -import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems'; +import { NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; +import { itemLike } from '../../core/properties/listItems'; +import { pathLabel, valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath'; import { type PropertyDef, schemaForAsset, @@ -32,16 +33,19 @@ export interface ListAccess { readonly def: ListDef; /** What is in the list now – re-read on every access. */ readonly value: ListValue; - /** - * The kinds this list may hold. A list in a struct field takes primitives only – `struct → list → - * struct` is past the depth the stored value goes, so offering it would add an item that the next - * write silently drops (`flatListOf`). - */ - readonly kinds: readonly ListItemKind[]; + /** The kinds this list may hold: every one, at any depth (#239). */ + readonly kinds: readonly NestedKind[]; /** What the tab header says the list belongs to. */ readonly title: string; /** One item, previewed and committed exactly as the inspector does it. */ itemHandlers(index: number): FieldHandlers; + /** + * One value inside an item – `[3, 'hp']` is field `hp` of item 3. Each write rebuilds the whole + * list from what is in it at that moment, the same rule as an item, only deeper. + */ + handlersAt(path: ValuePath): FieldHandlers; + /** Opens the List tab on a list inside this one, `path` leading from this list to it. */ + openList(path: ValuePath): void; /** A whole new list in one command – adding, deleting, and the bulk text field. */ replace(next: ListValue): void; } @@ -56,7 +60,7 @@ interface Owner { } export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null { - const inSlot = (owner: Owner | null) => owner && listIn(state, owner, slot.field); + const inSlot = (owner: Owner | null) => owner && listIn(state, owner, slot); if (slot.kind === 'asset') return inSlot(assetOwner(state, slot)); if (slot.kind === 'object') return inSlot(objectOwner(state, slot)); return inSlot(nodeOwner(state, slot)); @@ -64,42 +68,43 @@ export function resolveList(state: EditorState, slot: ListSlot): ListAccess | nu const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []); -const withItem = (list: ListValue, index: number, item: ListItem): ListValue => - list.map((value, i) => (i === index ? item : value)); - -/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */ -function listIn(state: EditorState, owner: Owner, field?: string): ListAccess | null { - if (field === undefined) +/** The owner's own list, or – with a `path` – a list nested inside its value. */ +function listIn(state: EditorState, owner: Owner, slot: ListSlot): ListAccess | null { + const path = slot.path ?? []; + const open = (inner: ValuePath) => state.openList({ ...slot, path: [...path, ...inner] }); + if (path.length === 0) return owner.def.kind === 'list' - ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec) + ? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, open) : null; - if (owner.def.kind !== 'struct') return null; - const declared = owner.def.fields.find((f) => f.key === field); - if (declared?.kind !== 'list') return null; - const fields = owner.def.fields; - const read = (target: Target) => { - const bundle = owner.spec.read(target); - return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]); - }; - // Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the - // list alone would drop its neighbours, and a snapshot would undo an edit made in between. - const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({ - ...structOf(fields, target === undefined ? undefined : owner.spec.read(target)), - [field]: asList(list), - }); + // A struct is brought into its declared shape first, so a declared list not stored yet is there. + const def = owner.def; + const shaped = (value: PropertyValue | undefined): PropertyValue | undefined => + def.kind === 'struct' ? structOf(def.fields, value) : value; + if (!isList(valueAt(shaped(owner.current()), path))) return null; + // Built on what the target stores, or – where it stores nothing, as an unset override – on what + // it shows: writing the list alone would drop its neighbours, and a snapshot would undo an edit + // made in between. + const whole = (target: Target | undefined) => + shaped((target === undefined ? undefined : owner.spec.read(target)) ?? owner.current()); + const wholeWith = (target: Target | undefined, list: PropertyValue): PropertyValue => + withValueAt(whole(target), path, asList(list)) ?? asList(list); + const last = pathLabel(path).at(-1) ?? ''; return access( state, - { key: field, label: field, kind: 'list', default: [] }, - `${owner.title} · ${field}`, - () => asList(structOf(fields, owner.current())[field]), + { key: last, label: last, kind: 'list', default: [] }, + [owner.title, ...pathLabel(path)].join(' · '), + () => asList(valueAt(shaped(owner.current()), path)), { target: owner.spec.target, - read, - write: (target, list) => owner.spec.write(target, bundleWith(target, list)), + read: (target) => { + const stored = owner.spec.read(target); + return stored === undefined ? undefined : asList(valueAt(shaped(stored), path)); + }, + write: (target, list) => owner.spec.write(target, wholeWith(target, list)), remove: owner.spec.remove, - commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)), + commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)), }, - FIELD_KINDS, + open, ); } @@ -114,43 +119,30 @@ function access( title: string, current: () => ListValue, spec: Omit, 'toModel'>, - kinds: readonly ListItemKind[] = LIST_ITEM_KINDS, + open: (path: ValuePath) => void, ): ListAccess { + // The value keeps the kind it has: a color item written from its field stays a color. + const handlersAt = (path: ValuePath): FieldHandlers => + previewingHandlers( + { state }, + { + ...spec, + toModel: (input) => + withValueAt(current(), path, itemLike(valueAt(current(), path), input)) ?? current(), + }, + ); return { def, - kinds, + kinds: NESTED_KINDS, title, // Re-read, not a snapshot: an item edit does not rebuild the rows, so a delete or kind change // clicked later has to build on the list with that edit in it, not on the one the rows showed. get value() { return current(); }, - itemHandlers: (index) => ({ - ...previewingHandlers( - { state }, - { - ...spec, - // The row keeps the kind it has: a color item written from its field stays a color. - toModel: (input) => withItem(current(), index, itemLike(current()[index], input)), - }, - ), - // A struct item is edited field by field, and each write rebuilds the whole item from what - // is in the list at that moment -- the same rule as a row, one level deeper. - structFieldHandlers: (field: { key: string }) => - previewingHandlers( - { state }, - { - ...spec, - toModel: (input) => { - const item = structItemOf(current()[index]); - return withItem(current(), index, { - ...item, - [field.key]: fieldLike(item[field.key], input), - }); - }, - }, - ), - }), + itemHandlers: (index) => handlersAt([index]), + handlersAt, + openList: open, // 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)), diff --git a/src/editor/state.ts b/src/editor/state.ts index a30843d6..a47f6f31 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -20,6 +20,7 @@ import { deserializeProject, serializeProject } from '../core/store/serialize'; import { requestRender } from '../ui'; import { consoleLog } from './console/log'; import type { GameScale, PlayFrom } from '../core/model/playSettings'; +import type { ValuePath } from '../core/properties/valuePath'; export type Selection = | { kind: 'asset'; assetId: string } @@ -55,10 +56,11 @@ export type Selection = /** * Which list the List tab edits. Deliberately not a `Selection`: a list is not something one * selects, it is a value inside something already selected, and exactly three places hold one. - * `field` names a list held by a struct field one level down; a struct item of a list needs no - * such step, because the List tab renders it inline. + * `path` leads to a list nested inside the value `key` names (#239): a key per struct field, an + * index per list item – `[3, 'drops']` is the list in field `drops` of item 3. A struct item needs + * no step of its own to be edited, because the List tab renders it inline. */ -export type ListSlot = { field?: string } & ( +export type ListSlot = { path?: ValuePath } & ( | { kind: 'asset'; assetId: string; key: string } | { kind: 'object'; holderId: string; objectId: string; key: string } | { kind: 'node'; assetId: string; nodeId: string; key: string } @@ -66,7 +68,9 @@ export type ListSlot = { field?: string } & ( function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean { if (!a || !b) return a === b; - if (a.field !== b.field) return false; + const pa = a.path ?? []; + const pb = b.path ?? []; + if (pa.length !== pb.length || pa.some((step, i) => step !== pb[i])) return false; switch (a.kind) { case 'asset': return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key; diff --git a/tests/core/kinds.test.ts b/tests/core/kinds.test.ts index f54a8c29..cb6f04d0 100644 --- a/tests/core/kinds.test.ts +++ b/tests/core/kinds.test.ts @@ -20,9 +20,8 @@ import { KINDS, kindLabel, kindMark, - LIST_ITEM_KINDS, + NESTED_KINDS, plainKindOf, - STRUCT_FIELD_KINDS, VARIABLE_KINDS, } from '../../src/core/properties/kinds'; import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems'; @@ -48,8 +47,7 @@ describe('the kind table', () => { expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind)); expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind)); } - expect(LIST_ITEM_KINDS).toEqual([...FIELD_KINDS, 'struct']); - expect(STRUCT_FIELD_KINDS).toEqual([...FIELD_KINDS, 'list']); + expect(NESTED_KINDS).toEqual([...FIELD_KINDS, 'list', 'struct']); }); it('reads nothing as the empty value of a kind, and the empty value as itself', () => { diff --git a/tests/core/list-item-kind.test.ts b/tests/core/list-item-kind.test.ts index e122490c..5b260e94 100644 --- a/tests/core/list-item-kind.test.ts +++ b/tests/core/list-item-kind.test.ts @@ -92,9 +92,13 @@ describe('the kind of one list item', () => { ]); }); - it('drops a list inside a struct item, the one level too deep', () => { - const { f, behaviorId } = withList([{ x: 1, deep: [1, 2] }]); - expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([{ x: 1 }]); + /** Once dropped as one level too deep; since #239 nothing bounds the depth. */ + it('keeps a list inside a struct item, and a list in a list', () => { + const { f, behaviorId } = withList([{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, [[3]]]); + expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([ + { x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, + [[3]], + ]); }); }); diff --git a/tests/core/list-of-structs.test.ts b/tests/core/list-of-structs.test.ts index c6ff2a2b..5fedd00d 100644 --- a/tests/core/list-of-structs.test.ts +++ b/tests/core/list-of-structs.test.ts @@ -84,12 +84,19 @@ describe('an untyped slot on load', () => { expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 }); }); - it('drops a struct inside a struct, which no declaration can ask for', () => { - expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 }); + /** Both were dropped while the depth was bounded; since #239 they come back as stored. */ + it('keeps a struct inside a struct', () => { + expect(paramsAfterLoad({ v: { inner: { x: 1, deeper: { y: 2 } }, n: 1 } }).v).toEqual({ + inner: { x: 1, deeper: { y: 2 } }, + n: 1, + }); }); - it('drops a list inside a list item, the level below what a field may hold', () => { - expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]); + it('keeps a list inside a list item, and drops only what is no value at all', () => { + expect(paramsAfterLoad({ items: [{ deep: [1, [2, null]], x: 1 }, [null, 3]] }).items).toEqual([ + { deep: [1, [2]], x: 1 }, + [3], + ]); }); }); diff --git a/tests/core/nested-values.test.ts b/tests/core/nested-values.test.ts new file mode 100644 index 00000000..b867645b --- /dev/null +++ b/tests/core/nested-values.test.ts @@ -0,0 +1,228 @@ +/** + * Structs and lists nest to any depth (#239): a struct field may be a struct with a declaration of + * its own, a list item a list, and nothing deeper is dropped – not on load, not on the way through + * a graph. What was stored under the old bound still loads as it was. + */ +import { describe, expect, it } from 'vitest'; +import { AddPortsCommand } from '../../src/core/commands/portCommands'; +import { + AddStructFieldCommand, + AddVariableCommand, + RemoveStructFieldCommand, + RenameStructFieldCommand, +} from '../../src/core/commands/variableCommands'; +import { writeTyped } from '../../src/core/graph'; +import { liveValue, withoutObjects } from '../../src/core/graph/liveObjects'; +import { asField, heldObject, type Value } from '../../src/core/graph/values'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { + NO_OBJECT, + type PropertyBag, + type PropertyValue, + sameValue, + tagged, +} from '../../src/core/model/Properties'; +import { variableDef, type StructFieldDef } from '../../src/core/properties/PropertySchema'; +import { valueAt, withValueAt } from '../../src/core/properties/valuePath'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { + fakeEntity, + graphEditor, + makeBehavior, + makeSpriteScene, + place, + roundTrip, + type SceneFixture, + startWorld, +} from '../helpers'; + +/** `enemy` = { hp, loot: { item, drops } }, `loot` declared inside the struct. */ +function enemyOn(f: SceneFixture): BehaviorAsset { + const behavior = makeBehavior(f, 'Enemy'); + const id = behavior.id; + const run = (command: Parameters[0]) => f.stack.execute(command); + run(new AddVariableCommand(id, 'enemy', 'struct')); + run(new AddStructFieldCommand(id, 'enemy', { key: 'hp', kind: 'number' })); + run(new AddStructFieldCommand(id, 'enemy', { key: 'loot', kind: 'struct', fields: [] })); + run(new AddStructFieldCommand(id, 'enemy', { key: 'item', kind: 'text' }, ['loot'])); + run(new AddStructFieldCommand(id, 'enemy', { key: 'drops', kind: 'list' }, ['loot'])); + return behavior; +} + +const declared = (behavior: BehaviorAsset) => behavior.variables.find((v) => v.key === 'enemy')!; + +describe('a struct field that is a struct', () => { + it('declares its own fields, and every value takes that shape at once', () => { + const f = makeSpriteScene(); + const behavior = enemyOn(f); + expect(declared(behavior)).toMatchObject({ + fields: [ + { key: 'hp', kind: 'number' }, + { + key: 'loot', + kind: 'struct', + fields: [ + { key: 'item', kind: 'text' }, + { key: 'drops', kind: 'list' }, + ], + }, + ], + }); + const empty = { hp: 0, loot: { item: '', drops: [] } }; + expect(declared(behavior).default).toEqual(empty); + expect(behavior.props.enemy).toEqual(empty); + }); + + it('brings what arrives into the declared shape, however deep', () => { + const f = makeSpriteScene(); + const fields = (declared(enemyOn(f)) as { fields: readonly StructFieldDef[] }).fields; + const def = variableDef('e', 'struct', { hp: '5', loot: { item: 3, extra: 1 }, other: 2 }, fields); + expect(def.default).toEqual({ hp: 5, loot: { item: '3', drops: [] } }); + }); + + it('keeps declaration and a deep value through save and load', () => { + const f = makeSpriteScene(); + const behavior = enemyOn(f); + const deep = { + hp: 3, + loot: { item: 'gem', drops: [{ name: 'a', tags: ['x', [1, tagged('color', 0xff0000)]] }, [[2]]] }, + }; + behavior.props.enemy = deep; + const back = roundTrip(f.project).assets.find((a) => a.id === behavior.id) as BehaviorAsset; + expect(back.props.enemy).toEqual(deep); + expect(declared(back)).toEqual(declared(behavior)); + }); + + it('adds, renames and removes a nested field in the declaration and every value, undo included', () => { + const f = makeSpriteScene(); + const behavior = enemyOn(f); + behavior.props.enemy = { hp: 1, loot: { item: 'gem', drops: [7] } }; + + f.stack.execute(new RenameStructFieldCommand(behavior.id, 'enemy', 'item', 'name', ['loot'])); + expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } }); + + f.stack.execute(new RemoveStructFieldCommand(behavior.id, 'enemy', 'drops', ['loot'])); + expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem' } }); + expect((declared(behavior) as { fields: readonly StructFieldDef[] }).fields).toEqual([ + { key: 'hp', kind: 'number' }, + { key: 'loot', kind: 'struct', fields: [{ key: 'name', kind: 'text' }] }, + ]); + + f.stack.undo(); + expect(behavior.props.enemy).toEqual({ hp: 1, loot: { name: 'gem', drops: [7] } }); + f.stack.undo(); + expect(behavior.props.enemy).toEqual({ hp: 1, loot: { item: 'gem', drops: [7] } }); + }); + + it('refuses a path that leads to no struct', () => { + const f = makeSpriteScene(); + const behavior = enemyOn(f); + expect(() => + f.stack.execute(new AddStructFieldCommand(behavior.id, 'enemy', { key: 'x', kind: 'number' }, ['hp'])), + ).toThrow('Field "hp" has no fields'); + expect(behavior.props.enemy).toEqual({ hp: 0, loot: { item: '', drops: [] } }); + }); +}); + +describe('stored values from before the bound was lifted', () => { + it('load exactly as they were', () => { + const f = makeSpriteScene(); + const behavior = makeBehavior(f, 'Old'); + f.stack.execute(new AddVariableCommand(behavior.id, 'rows', 'list')); + f.stack.execute(new AddVariableCommand(behavior.id, 'route', 'struct')); + f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'name', kind: 'text' })); + f.stack.execute(new AddStructFieldCommand(behavior.id, 'route', { key: 'stops', kind: 'list' })); + behavior.props.rows = [{ x: 1, c: tagged('color', 5) }, 2, 'a', tagged('texture', 't')]; + behavior.props.route = { name: 'n', stops: [1, tagged('color', 3)] }; + const stored = serializeProject(f.project); + expect(serializeProject(deserializeProject(stored))).toEqual(stored); + }); +}); + +describe('a nested value in a running game', () => { + /** Pack(item) into Pack(loot) into `enemy`; on every update Expand twice and log the item. */ + function packer(f: SceneFixture): BehaviorAsset { + const behavior = enemyOn(f); + const edit = graphEditor(f, behavior); + const inner = edit.addNode('PackStruct'); + f.stack.execute(new AddPortsCommand(behavior.id, inner.id, [{ key: 'item', kind: 'text' }])); + inner.params.item = 'gem'; + const outer = edit.addNode('PackStruct'); + f.stack.execute(new AddPortsCommand(behavior.id, outer.id, [{ key: 'loot', kind: 'struct' }])); + const set = edit.variables(['enemy'], { write: true }); + const get = edit.variables(['enemy']); + const first = edit.addNode('ExpandStruct'); + f.stack.execute(new AddPortsCommand(behavior.id, first.id, [{ key: 'loot', kind: 'struct' }])); + const second = edit.addNode('ExpandStruct'); + f.stack.execute(new AddPortsCommand(behavior.id, second.id, [{ key: 'item', kind: 'text' }])); + const log = edit.addNode('Log'); + edit.wire([ + [edit.addNode('OnStart'), 'exec', set, 'exec'], + [inner, 'value', outer, 'loot'], + [outer, 'value', set, 'enemy'], + [edit.addNode('OnUpdate'), 'exec', log, 'exec'], + [get, 'enemy', first, 'value'], + [first, 'loot', second, 'value'], + [second, 'item', log, 'message'], + ]); + place(f, behavior.id, { x: 0, y: 0 }); + return behavior; + } + + it('is written level by level and taken apart the same way', () => { + const f = makeSpriteScene(); + const behavior = packer(f); + const run = startWorld(f, { behaviors: true }); + run.frame(); + run.frame(); + expect(run.world.log.at(-1)).toBe('gem'); + // Merged into the declared shape: the fields nobody wrote keep their values. + const instance = run.world.entities.find((e) => e.assetId === behavior.id)!; + expect(instance.props.enemy).toEqual({ hp: 0, loot: { item: 'gem', drops: [] } }); + }); + + it('keeps a list in a list and a struct in a struct when written into a variable', () => { + const bag: PropertyBag = { grid: [], nest: { a: { b: 0 } } }; + writeTyped(bag, 'grid', [ + [1, 2], + [3, [4]], + ]); + writeTyped(bag, 'nest', { a: { b: 5, c: 1 } }); + expect(bag.grid).toEqual([ + [1, 2], + [3, [4]], + ]); + expect(bag.nest).toEqual({ a: { b: 5 } }); + }); + + it('holds an object at any depth, and lets none of them out', () => { + const crate = fakeEntity('Crate'); + const held = asField([{ who: { at: [crate] } }] as unknown as Value); + expect(valueAt(held, [0, 'who', 'at', 0])).toBe(heldObject(crate)); + expect(withoutObjects(held)).toEqual([{ who: { at: [NO_OBJECT] } }]); + expect(liveValue(held, () => false)).toEqual([{ who: { at: [NO_OBJECT] } }]); + expect(liveValue(held, () => true)).toEqual(held); + }); +}); + +describe('a path into a nested value', () => { + const value: PropertyValue = { a: [1, { b: [2, 3] }] }; + + it('reads what sits there, and nothing where a step finds nothing', () => { + expect(valueAt(value, ['a', 1, 'b', 0])).toBe(2); + expect(valueAt(value, ['a', 5])).toBeUndefined(); + expect(valueAt(value, ['a', 'b'])).toBeUndefined(); + }); + + it('writes a copy, leaving the value it started from alone', () => { + const next = withValueAt(value, ['a', 1, 'b', 1], 9); + expect(next).toEqual({ a: [1, { b: [2, 9] }] }); + expect(value).toEqual({ a: [1, { b: [2, 3] }] }); + expect(withValueAt(value, ['a', 7], 9)).toBe(value); + }); + + it('compares nested values by content', () => { + expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [2] }] })).toBe(true); + expect(sameValue({ a: [1, { b: [2] }] }, { a: [1, { b: [3] }] })).toBe(false); + }); +}); diff --git a/tests/core/object-variable.test.ts b/tests/core/object-variable.test.ts index a924fdd5..8612b5e7 100644 --- a/tests/core/object-variable.test.ts +++ b/tests/core/object-variable.test.ts @@ -19,12 +19,7 @@ import { bagWithoutObjects, liveValue } from '../../src/core/graph/liveObjects'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { NO_OBJECT, type PropertyBag } from '../../src/core/model/Properties'; import { findScene } from '../../src/core/model/Project'; -import { - isKindWith, - LIST_ITEM_KINDS, - STRUCT_FIELD_KINDS, - VARIABLE_KINDS, -} from '../../src/core/properties/kinds'; +import { isKindWith, NESTED_KINDS, VARIABLE_KINDS } from '../../src/core/properties/kinds'; import { makeItem } from '../../src/core/properties/listItems'; import { serializeProject } from '../../src/core/store/serialize'; import { @@ -123,8 +118,7 @@ const placedOf = (f: SceneFixture, behavior: BehaviorAsset) => describe('the object kind', () => { it('can be a variable, a struct field and a list item', () => { expect(VARIABLE_KINDS).toContain('object'); - expect(STRUCT_FIELD_KINDS).toContain('object'); - expect(LIST_ITEM_KINDS).toContain('object'); + expect(NESTED_KINDS).toContain('object'); expect(isKindWith('tagged', 'object')).toBe(true); }); diff --git a/tests/editor/list-slot.test.ts b/tests/editor/list-slot.test.ts index 5266514e..1689f190 100644 --- a/tests/editor/list-slot.test.ts +++ b/tests/editor/list-slot.test.ts @@ -11,7 +11,7 @@ import { deserializeProject, serializeProject } from '../../src/core/store/seria import type { BehaviorAsset } from '../../src/core/model/Asset'; import { tagged } from '../../src/core/model/Properties'; import { createEmptyProject, findAsset } from '../../src/core/model/Project'; -import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds'; +import { NESTED_KINDS } from '../../src/core/properties/kinds'; import { variableDef } from '../../src/core/properties/PropertySchema'; import { resolveList } from '../../src/editor/list/slot'; import { EditorState, type ListSlot } from '../../src/editor/state'; @@ -210,7 +210,7 @@ describe('a list a struct field holds', () => { expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list' }))).toBe( true, ); - slot = { kind: 'asset', assetId: id, key: 'route', field: 'stops' }; + slot = { kind: 'asset', assetId: id, key: 'route', path: ['stops'] }; }); const list = () => resolveList(f.state, slot)!; @@ -244,9 +244,9 @@ describe('a list a struct field holds', () => { }); it('is nothing at all for a field that is not a list, or is not there', () => { - expect(resolveList(f.state, { ...slot, field: 'name' })).toBe(null); - expect(resolveList(f.state, { ...slot, field: 'no-such' })).toBe(null); - expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', field: 'x' })).toBe( + expect(resolveList(f.state, { ...slot, path: ['name'] })).toBe(null); + expect(resolveList(f.state, { ...slot, path: ['no-such'] })).toBe(null); + expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', path: ['x'] })).toBe( null, ); }); @@ -264,21 +264,18 @@ describe('a list a struct field holds', () => { expect(asset.props.route).toEqual({ name: '', stops: [4, 5] }); }); - /** The one shape the model does not store: a list inside a struct inside a list. */ - it('is dropped inside the struct items of a list', () => { + /** Once the one shape the model dropped (#239): a list inside a struct inside a list. */ + it('keeps a list inside the struct items of a list', () => { const def = variableDef('rows', 'list', [{ x: 1, inner: [1, 2] }]); - expect(def.default).toEqual([{ x: 1 }]); + expect(def.default).toEqual([{ x: 1, inner: [1, 2] }]); }); - /** - * And because they are dropped, the tab must not offer them: an item added here would vanish on - * the next write, which reads as the menu entry doing nothing at all. - */ - it('offers the flat kinds only, where the whole list has struct as well', () => { - expect(list().kinds).toEqual(FIELD_KINDS); - expect(list().kinds).not.toContain('struct'); + /** Nothing is dropped any more, so a list in a struct field offers every kind, as the whole list does. */ + it('offers every kind, in a struct field as in the whole list', () => { + expect(list().kinds).toEqual(NESTED_KINDS); + expect(list().kinds).toContain('struct'); + expect(list().kinds).toContain('list'); const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!; - expect(whole.kinds).toEqual(LIST_ITEM_KINDS); - expect(whole.kinds).toContain('struct'); + expect(whole.kinds).toEqual(NESTED_KINDS); }); }); diff --git a/tests/editor/nested-list-slot.test.ts b/tests/editor/nested-list-slot.test.ts new file mode 100644 index 00000000..bfdd89a6 --- /dev/null +++ b/tests/editor/nested-list-slot.test.ts @@ -0,0 +1,135 @@ +/** + * Editing a value three levels deep (#239): the List tab reaches a nested list by a path of keys + * and indices, an inspector row a nested struct field by its keys. Either way one edit is one + * command on the whole value, so undo takes back exactly that edit and the neighbours stay. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import { createEmptyProject, findAsset } from '../../src/core/model/Project'; +import { requirePropDef } from '../../src/core/commands/require'; +import { structFieldsOf } from '../../src/editor/inspector/previewHandlers'; +import { resolveList } from '../../src/editor/list/slot'; +import { EditorState, type ListSlot } from '../../src/editor/state'; + +function setup(): { state: EditorState; id: string } { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('behavior', 'B'); + expect(state.execute(create)).toBe(true); + return { state, id: create.asset.id }; +} + +const props = (f: ReturnType) => (findAsset(f.state.project, f.id) as BehaviorAsset).props; + +describe('a list nested in the items of a list', () => { + let f: ReturnType; + const rows = [{ name: 'a', drops: [1, { x: [2, 3] }] }, 'plain']; + beforeEach(() => { + f = setup(); + expect(f.state.execute(new AddVariableCommand(f.id, 'rows', 'list'))).toBe(true); + resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows' })!.replace(rows); + }); + + const at = (path: ListSlot['path']) => + resolveList(f.state, { kind: 'asset', assetId: f.id, key: 'rows', path }); + + it('is found by its path, and the title spells the path out', () => { + expect(at([0, 'drops'])!.value).toEqual([1, { x: [2, 3] }]); + expect(at([0, 'drops'])!.title).toBe('B · rows · [0] · drops'); + expect(at([0, 'drops', 1, 'x'])!.value).toEqual([2, 3]); + expect(at([0, 'drops', 1, 'x'])!.title).toBe('B · rows · [0] · drops · [1] · x'); + }); + + it('is nothing where the path leads to no list', () => { + expect(at([0, 'name'])).toBe(null); + expect(at([1, 'drops'])).toBe(null); + expect(at([5])).toBe(null); + }); + + it('writes one deep item as one command on the whole list, and undo takes back just that', () => { + at([0, 'drops', 1, 'x'])!.itemHandlers(1).onChange(9); + expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']); + at([0, 'drops'])!.replace([1, { x: [2, 9] }, [4]]); + expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }, [4]] }, 'plain']); + + f.state.commands.undo(); + expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [2, 9] }] }, 'plain']); + f.state.commands.undo(); + expect(props(f).rows).toEqual(rows); + }); + + it('edits a value inside an item by its path, keeping the kind it has', () => { + const list = at([])!; + list.handlersAt([0, 'drops', 1, 'x', 0]).onChange('7'); + expect(props(f).rows).toEqual([{ name: 'a', drops: [1, { x: [7, 3] }] }, 'plain']); + }); + + it('previews a deep edit and still undoes to before the preview', () => { + const handlers = at([])!.handlersAt([0, 'name']); + handlers.onInput('ab'); + handlers.onInput('abc'); + handlers.onChange('abc'); + expect(props(f).rows).toEqual([{ name: 'abc', drops: [1, { x: [2, 3] }] }, 'plain']); + f.state.commands.undo(); + expect(props(f).rows).toEqual(rows); + }); + + it('opens the tab one level further down', () => { + at([0, 'drops'])!.openList([1, 'x']); + expect(f.state.listSlot).toEqual({ + kind: 'asset', + assetId: f.id, + key: 'rows', + path: [0, 'drops', 1, 'x'], + }); + }); +}); + +describe('a field of a struct declared inside a struct', () => { + let f: ReturnType; + beforeEach(() => { + f = setup(); + const run = (command: Parameters[0]) => + expect(f.state.execute(command)).toBe(true); + run(new AddVariableCommand(f.id, 'enemy', 'struct')); + run(new AddStructFieldCommand(f.id, 'enemy', { key: 'hp', kind: 'number' })); + run(new AddStructFieldCommand(f.id, 'enemy', { key: 'loot', kind: 'struct', fields: [] })); + run(new AddStructFieldCommand(f.id, 'enemy', { key: 'count', kind: 'number' }, ['loot'])); + run(new AddStructFieldCommand(f.id, 'enemy', { key: 'drops', kind: 'list' }, ['loot'])); + run(new SetAssetPropertyCommand(f.id, 'enemy', { hp: 5, loot: { count: 1, drops: ['a'] } })); + }); + + const handlers = () => { + const def = requirePropDef(f.state.project, findAsset(f.state.project, f.id)!, 'enemy'); + if (def.kind !== 'struct') throw new Error('enemy is not a struct'); + return structFieldsOf<{ props: Record }>( + { state: f.state }, + def, + { + target: () => findAsset(f.state.project, f.id), + read: (a) => a.props.enemy, + write: (a, value) => (a.props.enemy = value), + commit: (value) => new SetAssetPropertyCommand(f.id, 'enemy', value), + }, + (path) => ({ kind: 'asset', assetId: f.id, key: 'enemy', path }), + ); + }; + + it('writes the nested field and leaves every other one alone, one undo step', () => { + handlers()(['loot', 'count'], { key: 'count', kind: 'number' }).onChange(4); + expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 4, drops: ['a'] } }); + f.state.commands.undo(); + expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a'] } }); + }); + + it('opens a list field in the List tab by its path', () => { + handlers()(['loot', 'drops'], { key: 'drops', kind: 'list' }).openList!(); + const list = resolveList(f.state, f.state.listSlot!)!; + expect(list.title).toBe('B · enemy · loot · drops'); + list.replace(['a', ['b']]); + expect(props(f).enemy).toEqual({ hp: 5, loot: { count: 1, drops: ['a', ['b']] } }); + }); +}); diff --git a/tests/editor/struct-field-edit.test.ts b/tests/editor/struct-field-edit.test.ts index d697c387..57b161b2 100644 --- a/tests/editor/struct-field-edit.test.ts +++ b/tests/editor/struct-field-edit.test.ts @@ -69,13 +69,13 @@ describe('editing one field of a struct', () => { }); it('fills the untouched fields of a first override from the asset, not from an empty bundle', () => { - objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); + objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9); expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'T' }); }); it('leaves the others alone on a second edit, and undo goes back one field at a time', () => { - objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); - objectHandlers()({ key: 'tag', kind: 'text' }).onChange('hi'); + objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9); + objectHandlers()(['tag'], { key: 'tag', kind: 'text' }).onChange('hi'); expect(object().overrides.piece).toEqual({ x: 9, y: 4, tag: 'hi' }); f.state.commands.undo(); @@ -85,7 +85,7 @@ describe('editing one field of a struct', () => { }); it('builds on the preview while typing, and still undoes to before it', () => { - const handlers = objectHandlers()({ key: 'x', kind: 'number' }); + const handlers = objectHandlers()(['x'], { key: 'x', kind: 'number' }); handlers.onInput(1); handlers.onInput(12); handlers.onChange(12); @@ -96,7 +96,7 @@ describe('editing one field of a struct', () => { }); it('copies those values in rather than keeping them tied to the asset', () => { - objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); + objectHandlers()(['x'], { key: 'x', kind: 'number' }).onChange(9); f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' })); // The whole bundle is overridden now, so the asset moving on no longer reaches y. expect(resolveSceneObjectProps(f.state.project, asset(), object()).piece).toEqual({ @@ -117,7 +117,7 @@ describe('editing one field of a struct', () => { commit: (value) => new SetAssetPropertyCommand(f.behaviorId, 'piece', value), }, ); - handlers({ key: 'y', kind: 'number' }).onChange(8); + handlers(['y'], { key: 'y', kind: 'number' }).onChange(8); expect(resolveAssetProps(f.state.project, asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' }); }); }); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 41387459..baf7d9e4 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -105,11 +105,11 @@ export async function listTab(t) { // 21d. `+ Item` asks for a kind, since the list does not hold one for it. await clickText('+ Item'); check( - 'a list of its own offers every kind, the struct included', + 'a list of its own offers every kind, list and struct included', (await json('__gm.list.info()')).offers.join(',') === // In the order of the kind table (`core/properties/kinds.ts`) – a new kind is a row there, - // and `asset` (#237) and `object` (#238) are the newest of them. - 'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,struct', + // and `asset` (#237) and `object` (#238) are the newest of them. `list` since #239. + 'number,text,boolean,color,texture,statemachine,sound,scene,animation,asset,object,list,struct', JSON.stringify((await json('__gm.list.info()')).offers), ); await pickKind('Text'); @@ -136,6 +136,41 @@ export async function listTab(t) { ); await shot('03a-object-item'); await undo(); + + // 21d-iii. An item may be a list again (#239): its row is an item count, and a click opens the + // List tab on that list, one level down, with the path in the title. + const nums = () => json("__gm.state.project.assets.find((a) => a.id === 'listy').props.nums"); + await clickText('+ Item'); + await pickKind('List'); + await sleep(200); + info = await json('__gm.list.info()'); + check( + 'a List item is added as an empty list', + info.kinds.join(',') === 'number,number,number,list' && JSON.stringify((await nums())[3]) === '[]', + JSON.stringify(info), + ); + const innerCount = await json('__gm.list.fieldAt(3)'); + await click(innerCount.x, innerCount.y); + await sleep(200); + info = await json('__gm.list.info()'); + check( + 'clicking its count opens that list, the path in the title', + info.title.endsWith('· nums · [3]') && info.items.length === 0, + JSON.stringify(info), + ); + await clickText('+ Item'); + await pickKind('Number'); + await sleep(200); + check( + 'an item added there lands in the list inside the list', + JSON.stringify((await nums()).slice(3)) === '[[0]]', + JSON.stringify(await nums()), + ); + await shot('03b-nested-list'); + await undo(); + await undo(); + await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })"); + await sleep(200); await shot('03-rows'); // 21e. Back on the scene tab the button carries the current count. diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 61375bb9..03851c3e 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -139,12 +139,13 @@ export async function structVariable(t) { const info = await json('__gm.list.info()'); check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title); - // The depth the stored value goes ends here: this list sits in a struct field, so a struct item - // would be dropped by the next write. It is left out of the menu rather than doing nothing. + // Nothing bounds the depth any more (#239): a list in a struct field offers what any list does. await clickText('+ Item'); check( - 'the menu of a list in a struct field leaves the struct kind out', - (await find('Number')) !== null && (await find('Struct')) === null, + 'the menu of a list in a struct field offers every kind, the struct included', + (await find('Number')) !== null && + (await find('Struct')) !== null && + (await json('__gm.list.info()')).offers.includes('list'), JSON.stringify((await json('__gm.list.info()')).offers), ); await pickKind('Number'); @@ -165,6 +166,52 @@ export async function structVariable(t) { await mutate('__gm.state.openList(null)'); + // 22f-ii. A field may be a struct again (#239): a head row with a `+ Field` of its own, and its + // fields one indent step further in. Both menus with real clicks. + const declaredLast = () => + json("__gm.state.project.assets.find((a) => a.id === 'structy').variables[0].fields.at(-1)"); + await clickText('+ Field'); + await pickKind('Struct'); + await sleep(250); + check( + '+ Field ▸ Struct gives the bundle an empty struct', + JSON.stringify((await piece()).field1) === '{}', + JSON.stringify(await piece()), + ); + // The nested `+ Field` is drawn after the head's, so it is the last one. + const nestedAdd = await json(`__gm.locate('+ Field', true)`); + const headAdd = await json(`__gm.locate('+ Field')`); + check( + 'and that struct has its own + Field', + nestedAdd.y > headAdd.y, + JSON.stringify({ nestedAdd, headAdd }), + ); + await click(nestedAdd.x, nestedAdd.y); + await pickKind('Number'); + await sleep(250); + check( + 'which adds a field one level down, in the value', + JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":{"field1":0}}', + JSON.stringify(await piece()), + ); + check( + 'and in the declaration', + JSON.stringify(await declaredLast()) === + '{"key":"field1","kind":"struct","fields":[{"key":"field1","kind":"number"}]}', + JSON.stringify(await declaredLast()), + ); + const outerName = await json(`__gm.locate('field1')`); + const innerName = await json(`__gm.locate('field1', true)`); + check( + 'its field sits below it, one indent step further in', + innerName.y > outerName.y && innerName.x > outerName.x, + JSON.stringify({ outerName, innerName }), + ); + await shot('03b-nested-struct'); + await undo(); + await undo(); + check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece())); + // 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a // field edit rebuilds the whole bundle, so the untouched fields have to come from what the row // shows (the asset's values) rather than from the empty form of the declaration.