Give a list one struct type so every item keeps its shape (#254)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m38s

A list of structs used to let every item carry its own fields. Adding
an item meant typing the same fields again, and nothing kept the items
in step with the struct asset from #253. A list can now name a struct
type for its items, and the asset keeps them in shape.

- A list variable and a list field get the row Items with Mixed, every
  struct asset and Make struct type. Make struct type collects every
  field any item has, so no item loses a field.
- A typed item carries its type under the key `$struct`
  (ITEM_TYPE_KEY). listItems does not count it as a field.
- A list that holds anything but structs refuses a type, also when
  the offending item sits in an override. The console names the list
  and the count, and the list stays as it was.
- In a typed list `+ Item` adds an item with the defaults, without the
  kind menu. Field names are locked, values stay editable.
- The rows of a typed item take their kind from the struct asset
  (declaredKind in InlineList.ts). An asset field shows the asset
  picker instead of a text field, as reported in the issue.
- A struct item in a mixed list gets its own Type row. Switching a
  list back to Mixed keeps the type on each item.
- syncStructTypes brings marked items into shape and carries renames
  into them, also inside overrides and nested lists. One undo step.
- The delete dialog of a struct asset also counts typed lists.
- The game never sees the key. World and sharedState strip it with
  withoutItemTypes and bagWithoutItemTypes, so a struct stays a
  plain object at runtime.
- Add item and Set item get the setting Type. With a type they only
  accept a struct with exactly its field names (fitsType in
  SetItem.ts). Anything else is left out and logged.
- The item indent from #252 is back to its old width, the narrow one
  squeezed the value fields.

Not done: writing a variable does not check the type, and the List
node offers no type.

Found on the way: a test helper in list-of-structs.test.ts called
nodes without params. It now passes the same arguments as the
interpreter.

Tests:
- ./check.sh: 183 files, 2679 tests, CHECK EXIT 0
- e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks
- e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks
- New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks
- Counter-checks bite in unit and browser, originals restored by cmp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 07:23:00 +02:00
1 parent 61bbb96a96
commit b0d0868f55
25 files changed
+1446 -86

No files matched your search

+5 -3
View File
@@ -134,14 +134,16 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const animations = picture ? animationsUsing(state.project, ids).length : 0;
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, nodes: 0 };
const typed =
asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 };
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: eased.keys > 0 || eased.nodes > 0
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
: typed.places > 0 || typed.nodes > 0
? `${typed.places} struct(s) and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.`
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
? `${typed.places} struct(s)${lists} and ${typed.nodes} Pack/Expand node(s) use it and keep its fields as their own.`
: references > 0 || animations > 0 || tiles > 0
? wornDetail(references, animations, tiles, asset.type)
: usages === 0
+3
View File
@@ -106,6 +106,9 @@ export function documentHooks({ state, editor }: HookContext) {
removeAt: (where: ListPlace, title: string, index: number) =>
listIn(where, title)?.removeAt(index) ?? null,
moreAt: (where: ListPlace, title: string) => listIn(where, title)?.moreAt() ?? null,
/** The Type picker of a struct item in a mixed list, or of a struct field inside it (#254). */
typeAt: (where: ListPlace, title: string, index: number, field = '') =>
listIn(where, title)?.typeAt(index, field) ?? null,
/** The head of a list: its "+ Item" and – where it has one – its "Open". */
headAt: (where: ListPlace, title: string) => {
const head = listIn(where, title)?.head;
+33 -3
View File
@@ -28,7 +28,9 @@ import {
type VariablesAsset,
} from '../../core/model/Asset';
import {
MakeListItemTypeCommand,
MakeStructTypeCommand,
SetListItemTypeCommand,
SetStructTypeCommand,
WithStructTypes,
} from '../../core/commands/structTypeCommands';
@@ -90,7 +92,9 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe
.flatMap((def): string[] =>
def.kind === 'struct'
? [structAssetOf(project, def.structType)?.name ?? '', typeNames(project, def.fields)]
: [],
: def.kind === 'list'
? [structAssetOf(project, def.itemType)?.name ?? '']
: [],
)
.join(',');
}
@@ -98,8 +102,14 @@ function typeNames(project: Project, rows: readonly (PropertyDef | StructFieldDe
export function defSignature(def: PropertyDef | StructFieldDef): string {
// Only a struct has rows of its own, at any depth; a list's items rebuild themselves (#252).
const fields = def.kind === 'struct' ? def.fields.map(defSignature).join('+') : '';
// A type decides which of those rows may be changed, and names itself in a row (#253).
const type = def.kind === 'struct' && def.structType ? `@${def.structType}` : '';
// A type decides which of those rows may be changed, and names itself in a row (#253) – so does
// the item type of a list (#254).
const type =
def.kind === 'struct' && def.structType
? `@${def.structType}`
: def.kind === 'list' && def.itemType
? `@${def.itemType}`
: '';
return `${def.key}:${def.kind}${type}${fields ? `(${fields})` : ''}`;
}
@@ -311,6 +321,26 @@ function structTyping(host: InspectorHost, owner: VariableOwnerAsset, key: strin
];
return entries;
},
listMenu: (path, itemType) => {
const current = structAssetOf(project(), itemType);
const target = { ownerId: owner.id, key, path };
// A list starts empty, so unlike a field a list may hold the type it sits in.
const types = project().assets.filter((a) => a.type === 'struct');
return [
{ label: 'Mixed', current: !current, onSelect: () => run(new SetListItemTypeCommand(target, '')) },
...types.map((type) => ({
label: type.name,
current: type.id === current?.id,
onSelect: () => run(new SetListItemTypeCommand(target, type.id)),
})),
{ separator: true },
{
label: 'Make struct type',
disabled: current !== undefined,
onSelect: () => run(new MakeListItemTypeCommand(target)),
},
];
},
};
}
+2 -6
View File
@@ -317,14 +317,10 @@ export function withBelow(row: Widget, field: Field, depth: number): Widget {
return stack;
}
/**
* A widget `depth` indent steps in, as far as a struct field of that depth. A list's items go one
* step less: their index column already reads as the step in.
*/
/** A widget `depth` indent steps in, as far as a struct field of that depth. */
export function indentedBy(widget: Widget, depth: number): HStack {
const steps = widget instanceof InlineList ? Math.max(0, depth - 1) : depth;
const line = new HStack({ gap: 0 });
if (steps > 0) line.add(new Spacer(steps * (FIELD_INDENT + Theme.gap)));
if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap)));
line.add(widget, 1);
return line;
}
+23 -2
View File
@@ -65,6 +65,8 @@ export interface StructTyping {
/** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */
typeName: (structType: string | undefined) => string | null;
menu: (path: readonly string[], structType: string | undefined) => MenuEntry[];
/** The item type of a list (#254); `path` leads to the list field, none for a list variable. */
listMenu: (path: readonly string[], itemType: string | undefined) => MenuEntry[];
}
export interface VariableOptions {
@@ -105,6 +107,8 @@ export function buildVariable(
if (edit) head.add(removeButton(edit.remove));
const refreshActions = actions ? attachRowActions(head, actions) : () => {};
host.body.add(head);
if (def.kind === 'list' && typing)
host.body.add(itemsRow(1, typing.typeName(def.itemType), () => typing.listMenu([], def.itemType)));
if (below) host.body.add(indentedBy(below, 1));
if (def.kind === 'struct') {
@@ -190,6 +194,14 @@ function structFieldRows(
setters.set(field.key, addValue(row, sub));
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row);
if (field.kind === 'list' && typing) {
const inner = [...path, field.key];
host.body.add(
itemsRow(path.length + 2, typing.typeName(field.itemType), () =>
typing.listMenu(inner, field.itemType),
),
);
}
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
}
return (next) => {
@@ -205,9 +217,18 @@ function fieldRow(field: StructFieldDef, path: readonly string[], edit: Variable
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
const picker = new Dropdown(typeName ?? 'Own fields', { variant: 'ghost' });
return pickerRow(depth, 'Type', typeName ?? 'Own fields', menu);
}
/** Which Struct asset every item of a list is (#254), as a row of its own under the list's head. */
function itemsRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack {
return pickerRow(depth, 'Items', typeName ?? 'Mixed', menu);
}
function pickerRow(depth: number, label: string, text: string, menu: () => MenuEntry[]): HStack {
const picker = new Dropdown(text, { variant: 'ghost' });
picker.onClick = () => new Menu(menu()).openBelow(picker);
const name = new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
const name = new Label(label, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding });
const row = gridRow(depth, new Spacer(MARK_WIDTH), name);
row.add(picker, 1);
return row;
+186 -29
View File
@@ -5,7 +5,9 @@
* step further in.
*
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit.
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit. A typed list
* (#254) is the exception: its items are its type, fixed – and in a mixed list a struct item may pick
* a type of its own, which is a value edit again, since the item carries it.
*/
import {
Button,
@@ -13,6 +15,7 @@ import {
HStack,
Label,
Menu,
type MenuEntry,
ScrollContainer,
Spacer,
TextField,
@@ -25,11 +28,16 @@ import {
type FieldValue,
isList,
isStruct,
itemTypeOf,
type ListValue,
type PropertyValue,
type StructValue,
textOf,
untag,
withItemType,
} from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
import { nextFieldName } from '../../core/commands/naming';
import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
import {
@@ -39,22 +47,23 @@ import {
structItemFields,
structItemOf,
} from '../../core/properties/listItems';
import { flatDef } from '../../core/properties/PropertySchema';
import { flatDef, type StructFieldDef } from '../../core/properties/PropertySchema';
import { valueAt, type ValuePath, withValueAt } from '../../core/properties/valuePath';
import {
addValue,
createField,
type FieldContext,
FIELD_INDENT,
MARK_WIDTH,
nameWidth,
removeButton,
} from '../inspector/fieldFactory';
import { kindMenu, markPicker } from '../inspector/variableRows';
import { kindMenu, markLabel, markPicker } from '../inspector/variableRows';
import { shownItems } from '../variables/target';
import type { ListAccess } from './slot';
/** Two digits; a longer index widens its own label. */
const INDEX_WIDTH = 16;
/** Room for three digits; a longer index widens its own label. */
const INDEX_WIDTH = 28;
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
interface RowWidgets {
@@ -64,6 +73,12 @@ interface RowWidgets {
parts: Map<string, Widget>;
marks: Map<string, Widget>;
names: Map<string, Widget>;
/** The kind each struct field is drawn as – the type's where it has one (#254). */
kinds: Map<string, NestedKind>;
/** The Type picker of the struct item (`''`) and of struct fields inside it. */
types: Map<string, Widget>;
/** A struct item drawn with its type's fields: no names to change, no + Field. */
locked: boolean;
}
const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
@@ -72,6 +87,9 @@ const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
parts: new Map(),
marks: new Map(),
names: new Map(),
kinds: new Map(),
types: new Map(),
locked: false,
});
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
@@ -102,9 +120,13 @@ export class ListHead extends HStack {
this.add(this.count);
this.add(new Spacer(), 1);
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
// Built on click, not once: which kinds fit depends on the list as it is then.
this.addButton.onClick = () =>
new Menu(kindMenu(access()?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton);
// Built on click, not once: which kinds fit depends on the list as it is then. A typed list
// has one kind only, so there is nothing to pick.
this.addButton.onClick = () => {
const now = access();
if (now?.itemType) add('struct');
else new Menu(kindMenu(now?.kinds ?? NESTED_KINDS, add)).openBelow(this.addButton);
};
this.add(this.addButton);
this.setCount(now?.value.length ?? 0);
}
@@ -149,10 +171,20 @@ export class InlineList extends VStack {
return this.access()?.title ?? '';
}
/** Adds an item of `kind` at the end – the head's `+ Item`. */
/** Adds an item of `kind` at the end – the head's `+ Item`; in a typed list one of its type. */
addItem(kind: NestedKind): void {
const access = this.access();
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
if (!access) return;
const type = this.listType(access);
access.replace([...access.value, type ? typedItem(this.project, type) : makeItem(kind, undefined)]);
}
private get project(): Project {
return this.ctx.project();
}
private listType(access: ListAccess) {
return structAssetOf(this.project, access.itemType);
}
/** Re-reads the list: new values go into the rows, a new structure builds them again. */
@@ -193,6 +225,11 @@ export class InlineList extends VStack {
return centreOf(this.rowWidgets[index]?.names.get(field));
}
/** For the check scripts: the Type picker of a struct item, or of a struct field inside it. */
typeAt(index: number, field = ''): { x: number; y: number } | null {
return centreOf(this.rowWidgets[index]?.types.get(field));
}
/** For the check scripts: the "… N more" line, which opens the list in its tab. */
moreAt(): { x: number; y: number } | null {
return centreOf(this.more ?? undefined);
@@ -206,10 +243,28 @@ export class InlineList extends VStack {
offers: NestedKind[];
rows: number;
more: number;
itemType: string;
/** The type each item is marked with, `''` for none. */
types: string[];
/** Per drawn row: whether its fields are the type's, fixed. */
locked: boolean[];
/** Per drawn row: the kind each struct field is drawn as, by its dotted id. */
fieldKinds: Record<string, NestedKind>[];
} {
const access = this.access();
const base = { rows: this.rowWidgets.length, more: 0 };
if (!access) return { ...base, title: '', items: [], kinds: [], offers: [] };
if (!access)
return {
...base,
title: '',
items: [],
kinds: [],
offers: [],
itemType: '',
types: [],
locked: [],
fieldKinds: [],
};
return {
...base,
// Drawn, not read: `items` re-reads the list, `rows` is what the last build put on screen.
@@ -218,6 +273,10 @@ export class InlineList extends VStack {
items: access.value.map((item) => textOf(item)),
kinds: access.value.map(itemKindOf),
offers: [...access.kinds],
itemType: access.itemType ?? '',
types: access.value.map((item) => itemTypeOf(item) ?? ''),
locked: this.rowWidgets.map((row) => row.locked),
fieldKinds: this.rowWidgets.map((row) => Object.fromEntries(row.kinds)),
};
}
@@ -225,8 +284,14 @@ export class InlineList extends VStack {
private signatureOf(access: ListAccess | null): string {
if (!access) return '';
const { shown } = shownItems(access.value.length, this.options.limit);
const shape = access.value.slice(0, shown).map(shapeOf).join(',');
return `${access.title}|${shape}|${access.value.length}`;
// A type's name is on screen too, so a renamed or deleted type has to rebuild the rows.
const typeName = (id: string | undefined) =>
id ? `@${structAssetOf(this.project, id)?.name ?? ''}` : '';
const shape = access.value
.slice(0, shown)
.map((item) => shapeOf(item, typeName))
.join(',');
return `${access.title}|${typeName(access.itemType)}|${shape}|${access.value.length}`;
}
private scrollArea(): ScrollContainer | null {
@@ -276,7 +341,8 @@ export class InlineList extends VStack {
label.minWidth = INDEX_WIDTH;
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const mark = this.kindPicker(access, [index], kind);
// A typed list's items are its type: there is no other kind to turn one into.
const mark = this.listType(access) ? markLabel(kind) : this.kindPicker(access, [index], kind);
if (tall) mark.minHeight = Theme.rowHeight;
row.add(mark);
let below: Widget | null = null;
@@ -339,12 +405,20 @@ export class InlineList extends VStack {
);
}
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
/**
* A struct item carries its own shape, so its fields are named, typed and removed right here –
* unless it has a type (#254): of the list, or picked for the item. Then its fields are the type's.
*/
private addStructItem(access: ListAccess, row: HStack, mark: Widget, index: number): void {
const stack = new VStack({ gap: Theme.gap });
const widgets = rowWidgets(mark, stack);
const leaves: Leaf[] = [];
this.addStructFields(access, stack, [index], 0, widgets, leaves);
const item = valueAt(access.value, [index]);
const typed = this.listType(access) !== undefined;
if (!typed && access.typable) stack.add(this.typeLine(access, [index], 0, widgets, ''));
const declared = this.declaredFields(this.listType(access)?.id ?? itemTypeOf(item));
widgets.locked = declared !== undefined;
this.addStructFields(access, stack, [index], 0, widgets, leaves, declared);
row.add(stack, 1);
this.setters.push((next) => {
for (const leaf of leaves) leaf.set(untag(valueAt(next, leaf.within)) ?? '');
@@ -352,10 +426,19 @@ export class InlineList extends VStack {
this.rowWidgets.push(widgets);
}
/** The fields of a Struct asset, or none for a struct of its own fields. */
private declaredFields(typeId: string | undefined): readonly StructFieldDef[] | undefined {
const type = structAssetOf(this.project, typeId);
return type ? typeFields(type) : undefined;
}
/**
* The fields of the struct at `path`, a line each; a field that is a struct again gets its own
* lines below, indented one step further (#239), and so does the item list of a list field. Check
* scripts find a nested field by its keys joined with dots (`loot.count`).
*
* `declared` are the type's fields when the struct has one: its shape is fixed then, and a field
* is the kind the type says – an item keeps an asset id as a plain text, which alone reads as one.
*/
private addStructFields(
access: ListAccess,
@@ -364,17 +447,24 @@ export class InlineList extends VStack {
depth: number,
widgets: RowWidgets,
leaves: Leaf[],
declared: readonly StructFieldDef[] | undefined,
): void {
const locked = declared !== undefined;
// A field value edit does not rebuild the row, so every button reads the struct as it is now.
const now = () => structItemOf(valueAt(access.value, path));
for (const field of structItemFields(valueAt(access.value, path))) {
for (const own of structItemFields(valueAt(access.value, path))) {
const decl = declared?.find((d) => d.key === own.key);
const field = { key: own.key, kind: declaredKind(own.kind, decl) };
const fieldPath = [...path, field.key];
const id = fieldPath.slice(1).join('.');
const line = indentedLine(depth);
const fieldMark = this.kindPicker(access, fieldPath, field.kind);
const name = nameField(field.key, depth, (to) => {
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
});
// The fields of a typed struct are the type's: its values change here, its shape on the asset.
const fieldMark = locked ? markLabel(field.kind) : this.kindPicker(access, fieldPath, field.kind);
const name = locked
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
: nameField(field.key, depth, (to) => {
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
});
line.add(fieldMark);
line.add(name);
let below: Widget | null = null;
@@ -391,19 +481,73 @@ export class InlineList extends VStack {
widgets.parts.set(id, sub.widget);
}
widgets.marks.set(id, fieldMark);
widgets.kinds.set(id, field.kind);
widgets.names.set(id, name);
line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
if (!locked) line.add(removeButton(() => this.replaceAt(access, path, withoutField(now(), field.key))));
stack.add(line);
// Items under the field's own line: their index column is the step in.
if (below) stack.add(indented(below, depth * (FIELD_INDENT + Theme.gap)));
if (field.kind === 'struct') this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves);
// Items under the field's own line, one step further in than the field.
if (below) stack.add(indented(below, (depth + 1) * (FIELD_INDENT + Theme.gap)));
if (field.kind === 'struct') {
// A struct in a struct of its own fields may take a type as well; inside a type it is the type's.
if (!locked && access.typable) stack.add(this.typeLine(access, fieldPath, depth + 1, widgets, id));
const inner =
decl?.kind === 'struct'
? (this.declaredFields(decl.structType) ?? decl.fields)
: locked
? []
: this.declaredFields(itemTypeOf(valueAt(access.value, fieldPath)));
this.addStructFields(access, stack, fieldPath, depth + 1, widgets, leaves, inner);
}
}
if (locked) return;
const foot = indentedLine(depth);
foot.add(addFieldMenu((kind) => this.addField(access, path, kind)));
foot.add(new Spacer(), 1);
stack.add(foot);
}
/**
* Which Struct asset the struct at `path` follows (#254): its own fields, or a type – picking one
* brings the struct into that shape, keeping what it has of the type's fields.
*/
private typeLine(
access: ListAccess,
path: ValuePath,
depth: number,
widgets: RowWidgets,
id: string,
): HStack {
const current = structAssetOf(this.project, itemTypeOf(valueAt(access.value, path)));
const picker = new Dropdown(current?.name ?? 'Own fields', { variant: 'ghost' });
const struct = (): StructValue => structItemOf(valueAt(access.value, path));
picker.onClick = () => {
const entries: MenuEntry[] = [
{
label: 'Own fields',
current: !current,
onSelect: () => this.replaceAt(access, path, withItemType(struct(), '')),
},
...this.project.assets
.filter((a) => a.type === 'struct')
.map((type) => ({
label: type.name,
current: type.id === current?.id,
onSelect: () => {
const asset = structAssetOf(this.project, type.id);
if (asset) this.replaceAt(access, path, typedItem(this.project, asset, struct()));
},
})),
];
new Menu(entries).openBelow(picker);
};
widgets.types.set(id, picker);
const line = indentedLine(depth);
line.add(new Spacer(MARK_WIDTH));
line.add(new Label('Type', { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }));
line.add(picker, 1);
return line;
}
private addField(access: ListAccess, path: ValuePath, kind: NestedKind): void {
const bundle = valueAt(access.value, path);
const key = nextFieldName(structItemFields(bundle));
@@ -417,6 +561,16 @@ export class InlineList extends VStack {
}
}
/**
* The kind a field is drawn as: the declared one for a value that is not a list or a struct – a
* struct or list stays what its value is until the type brings it into shape.
*/
function declaredKind(own: NestedKind, decl: StructFieldDef | undefined): NestedKind {
if (!decl || own === 'list' || own === 'struct' || decl.kind === 'list' || decl.kind === 'struct')
return own;
return decl.kind;
}
/** One value of a struct item's tree: where it sits inside the item, and how its widget is set. */
interface Leaf {
within: ValuePath;
@@ -465,11 +619,14 @@ const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem
return next;
};
/** An item's kind, and for a struct the shape of every field below it – what its rows are built from. */
function shapeOf(item: PropertyValue | undefined): string {
/**
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
* are built from.
*/
function shapeOf(item: PropertyValue | undefined, typeName: (id: string | undefined) => string): string {
const kind = itemKindOf(item);
if (kind !== 'struct' || !isStruct(item)) return kind;
return `struct(${structItemFields(item)
.map((f) => `${f.key}:${shapeOf(item[f.key])}`)
return `struct${typeName(itemTypeOf(item))}(${structItemFields(item)
.map((f) => `${f.key}:${shapeOf(item[f.key], typeName)}`)
.join(',')})`;
}
+13 -1
View File
@@ -9,6 +9,7 @@ import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import { listItemTypeAt } from '../../core/model/structTypes';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyBag, type PropertyValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
@@ -56,6 +57,10 @@ export interface ListAccess {
sub(path: ValuePath): ListAccess | null;
/** A whole new list in one command – adding, deleting, and the bulk text field. */
replace(next: ListValue): void;
/** The Struct asset every item is (#254), declared on the list or on the typed item it sits in. */
readonly itemType: string | undefined;
/** Whether a struct item may pick a type of its own – not in a node's list, which a run reads as it is. */
readonly typable: boolean;
}
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
@@ -86,6 +91,7 @@ const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ?
/** The owner's own list, or – with a `path` – a list nested inside its value. */
function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot): ListAccess | null {
const path = slot.path ?? [];
const itemType = () => listItemTypeAt(state.project, owner.def, owner.current(), path);
const nested: Nested = {
open:
slot.kind === 'node'
@@ -95,7 +101,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot
};
if (path.length === 0)
return owner.def.kind === 'list'
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested)
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec, nested, itemType)
: null;
// A struct is brought into its declared shape first, so a declared list not stored yet is there.
const def = owner.def;
@@ -126,6 +132,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, slot: ListSlot
commit: (list) => owner.spec.commit(wholeWith(owner.spec.target(), list)),
},
nested,
itemType,
);
}
@@ -147,6 +154,7 @@ function access<Target>(
current: () => ListValue,
spec: Omit<PreviewedValue<Target>, 'toModel'>,
nested: Nested,
itemType: () => string | undefined,
): ListAccess {
// The value keeps the kind it has: a color item written from its field stays a color.
const handlersAt = (path: ValuePath): FieldHandlers =>
@@ -174,6 +182,10 @@ function access<Target>(
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
// A preview does not, which is why `toModel` above has to.
replace: (next) => state.execute(spec.commit(next)),
get itemType() {
return itemType();
},
typable: nested.open !== undefined,
};
}