Finish the struct and list leftovers: inline lists, list fields, item kinds

An unconnected list input is a button into the List tab instead of a number
field; a struct field can hold a list, so ListSlot carries an optional field
name and the three owner cases only say what sits at a key; and a list
variable picks its item kind in the inspector (marker doubles as the chooser),
so a list of structs no longer has to be born from a project file.

The declaration keeps the storable shapes finite: a struct field may be a list,
but that list holds primitives only, so list->struct->primitive and
struct->list->primitive are the two deepest forms.

check 674, e2e 483, jump-run 24, Tetris 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 16:36:15 +02:00
1 parent fe3c63c48c
commit 10d030376a
18 files changed
+984 -184

No files matched your search

+132 -27
View File
@@ -11,16 +11,21 @@ import {
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
SetListItemsCommand,
} from '../../core/commands/variableCommands';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import {
FIELD_KINDS,
type FieldKind,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
schemaForAsset,
STRUCT_FIELD_KINDS,
type StructDef,
type StructFieldDef,
type StructFieldKind,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
@@ -32,10 +37,11 @@ import {
createField,
createFieldRow,
type FieldHandlers,
fieldDef,
labelledRow,
primitiveDef,
structFieldValue,
} from './fieldFactory';
import type { ListDef } from '../list/slot';
import { addNote, addTitle, type InspectorHost } from './host';
import {
listOpener,
@@ -62,20 +68,43 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
struct: '⊞',
};
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
};
const FIELD_INDENT = 12;
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
/** The mark a list wears for what it holds – `≡` plus the item's own mark. */
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
struct: '⊞',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
number: 'Numbers',
boolean: 'Booleans',
text: 'Texts',
color: 'Colors',
struct: 'Structs',
};
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
};
const FIELD_INDENT = 12;
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
/**
@@ -88,8 +117,11 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef): string {
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+');
// A list's item kind decides whether it has field rows at all, so it is structure too.
const items = def.kind === 'list' ? `/${def.items}` : '';
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
@@ -181,7 +213,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec),
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
};
}
@@ -213,9 +245,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
}
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def));
row.add(
new TextField(def.key, {
width: 90,
@@ -236,9 +266,84 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def);
}
}
function kindMark(def: PropertyDef): Label {
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
return mark;
}
/**
* The kind mark of a list doubles as the chooser for what it holds – there is no room for a
* column of its own, and the mark is the one place that already says it. Changing it runs every
* value through the new declaration, so a list of numbers becomes a list of texts and back.
*/
function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown {
// Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron.
const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 });
picker.onClick = () => {
new Menu(
LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({
label: LIST_ITEM_LABELS[items],
onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)),
})),
).openBelow(picker);
};
return picker;
}
/**
* The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`).
* Their values are edited in the List tab, one row per item, which is why there is no value column.
*/
function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): void {
for (const field of def.fields ?? []) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(new Spacer(FIELD_INDENT));
const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
new TextField(field.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
}),
);
row.add(new Spacer(), 1);
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
}),
);
host.body.add(row);
}
const foot = new HStack({ gap: Theme.gap, align: 'center' });
foot.minHeight = Theme.rowHeight;
foot.add(new Spacer(FIELD_INDENT));
foot.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
FIELD_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }),
),
})),
).openBelow(add);
};
foot.add(add);
host.body.add(foot);
}
/**
* A struct variable is a block rather than a row: the head renames it and adds fields, each field
* below carries its own name, its value and a remove. That is the port editor `Pack` already has,
@@ -264,11 +369,11 @@ function buildStructVariable(
head.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
const items = FIELD_KINDS.map((kind) => ({
const items = STRUCT_FIELD_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind)),
),
}));
new Menu(items).openBelow(add);
@@ -284,7 +389,12 @@ function buildStructVariable(
host.body.add(head);
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key));
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
kind: 'asset',
assetId: asset.id,
key: def.key,
field,
}));
const fields = new Map<string, (item: PropertyValue) => void>();
for (const field of def.fields) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
@@ -300,12 +410,7 @@ function buildStructVariable(
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
}),
);
const item = createField(
host,
primitiveDef(field.key, field.kind),
structFieldValue(value, field),
perField(field),
);
const item = createField(host, fieldDef(field), structFieldValue(value, field), perField(field));
row.add(item.widget, item.grow ? 1 : 0);
if (!item.grow) row.add(new Spacer(), 1);
row.add(
+10 -8
View File
@@ -7,7 +7,7 @@ import {
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import {
emptyField,
emptyFieldValue,
type FieldKind,
type PropertyDef,
type StructFieldDef,
@@ -199,9 +199,16 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
/** A struct field as a property definition – a list field becomes the same row a list property has. */
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
return field.kind === 'list'
? { key: field.key, label, kind: 'list', default: [], items: field.items }
: primitiveDef(field.key, field.kind, label);
}
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind);
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
}
/** The bare widget for a property definition (no label, no reset). */
@@ -250,12 +257,7 @@ export function createField(
const fields = new Map<string, Field>();
for (const field of def.fields) {
const { row } = labelledRow(field.key);
const sub = createField(
ctx,
primitiveDef(field.key, field.kind),
structFieldValue(value, field),
per(field),
);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
row.add(sub.widget, sub.grow ? 1 : 0);
if (!sub.grow) row.add(new Spacer(), 1);
fields.set(field.key, sub);
+7 -1
View File
@@ -320,7 +320,13 @@ function propertyRow(
{
...previewingHandlers<ObjectRef>(host, overrideSpec),
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
...structFields<ObjectRef>(host, def, overrideSpec),
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({
kind: 'object',
holderId,
objectId,
key: def.key,
field,
})),
},
{
extra: aspectActions(
+13 -6
View File
@@ -1,5 +1,5 @@
import {
coerceField,
coerceFieldValue,
type PropertyDef,
type StructDef,
type StructFieldDef,
@@ -31,23 +31,30 @@ export function structFields<Target>(
host: { state: EditorState },
def: PropertyDef,
spec: PreviewedValue<Target>,
fieldSlot?: (field: string) => ListSlot,
): Pick<FieldHandlers, 'structFieldHandlers'> {
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {};
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. */
export function structFieldsOf<Target>(
host: { state: EditorState },
def: StructDef,
spec: PreviewedValue<Target>,
fieldSlot?: (field: string) => ListSlot,
): (field: StructFieldDef) => FieldHandlers {
return (field) =>
previewingHandlers<Target>(host, {
return (field) => ({
...previewingHandlers<Target>(host, {
...spec,
toModel: (input, target) => ({
...structOf(def.fields, spec.read(target)),
[field.key]: coerceField(input, field.kind),
[field.key]: coerceFieldValue(input, field),
}),
});
}),
...(field.kind === 'list' && fieldSlot
? { openList: () => host.state.openList(fieldSlot(field.key)) }
: {}),
});
}
/** Where an inspector row finds the value it edits, and what committing it means. */
+93 -39
View File
@@ -2,23 +2,24 @@
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
* three cases is the spec the matching inspector section already uses, so an item edited in the
* List tab goes through the same command, the same coercion and the same undo step as the row
* it replaces.
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
* level deeper, and the writes still go through the owner's command as a whole bundle.
*/
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { findNode, type GraphNode } from '../../core/graph/Graph';
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
coerceField,
coerceFieldValue,
coerceListItem,
type PropertyDef,
type PropertySchema,
schemaForAsset,
structOf,
type StructFieldDef,
@@ -43,19 +44,24 @@ export interface ListAccess {
replace(next: ListValue): void;
}
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
interface Owner<Target> {
def: PropertyDef;
/** Its effective value, re-read on every call – a preview writes into the model. */
current: () => PropertyValue | undefined;
title: string;
spec: Omit<PreviewedValue<Target>, 'toModel'>;
}
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
if (slot.kind === 'asset') return assetAccess(state, slot);
if (slot.kind === 'object') return objectAccess(state, slot);
return nodeAccess(state, slot);
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
return inSlot(nodeOwner(state, slot));
}
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
const def = schema.find((d) => d.key === key);
return def?.kind === 'list' ? def : null;
};
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
list.map((value, i) => (i === index ? item : value));
@@ -63,6 +69,41 @@ const withItem = (list: ListValue, index: number, item: number | string | boolea
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
structOf(def.fields ?? [], list[index]);
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
if (field === undefined)
return owner.def.kind === 'list'
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
: 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),
});
return access<Target>(
state,
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
`${owner.title} · ${field}`,
() => asList(structOf(fields, owner.current())[field]),
{
target: owner.spec.target,
read,
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
remove: owner.spec.remove,
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
},
);
}
/**
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
* the next keystroke has to build on what is in there now, and the commit on what the restore
@@ -99,7 +140,7 @@ function access<Target>(
toModel: (input) =>
withItem(current(), index, {
...structItemAt(current(), index, def),
[field.key]: coerceField(input, field.kind),
[field.key]: coerceFieldValue(input, field),
}),
},
),
@@ -112,67 +153,80 @@ function access<Target>(
};
}
function assetAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): ListAccess | null {
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
const asset = findAsset(state.project, slot.assetId);
const def = asset && listDefIn(schemaForAsset(asset), slot.key);
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
if (!asset || !def) return null;
const at = () => findAsset(state.project, slot.assetId);
return access<Asset>(
state,
return {
def,
`${asset.name} · ${def.label}`,
() => {
title: `${asset.name} · ${def.label}`,
current: () => {
const now = at();
return now ? asList(resolveAssetProps(now)[slot.key]) : [];
return now ? resolveAssetProps(now)[slot.key] : undefined;
},
{
spec: {
target: at,
read: (found) => found.props[slot.key],
write: (found, value) => (found.props[slot.key] = value),
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
},
);
};
}
function objectAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'object' }>): ListAccess | null {
function objectOwner(
state: EditorState,
slot: Extract<ListSlot, { kind: 'object' }>,
): Owner<ObjectRef> | null {
const ref = currentRef(state, slot.holderId, slot.objectId);
const owner = ref && findAsset(state.project, ref.object.assetId);
const def = owner && listDefIn(schemaForAsset(owner), slot.key);
const def = owner && schemaForAsset(owner).find((d) => d.key === slot.key);
if (!ref || !def) return null;
return access<ObjectRef>(
state,
return {
def,
`${ref.object.name} · ${def.label}`,
() => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]),
{
title: `${ref.object.name} · ${def.label}`,
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
spec: {
target: () => currentRef(state, slot.holderId, slot.objectId),
read: (found) => found.object.overrides[slot.key],
write: (found, value) => found.setOverride(slot.key, value),
remove: (found) => found.setOverride(slot.key, undefined),
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
},
);
};
}
function nodeAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): ListAccess | null {
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
const found = nodeIn(state, slot);
if (!found) return null;
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
const def = nodeDef && listDefIn(nodeDef.params, slot.key);
if (!def) return null;
return access<GraphNode>(
state,
const def =
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
if (!nodeDef || !def) return null;
return {
def,
`${nodeDef.label} · ${def.label}`,
() => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default),
{
title: `${nodeDef.label} · ${def.label}`,
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
spec: {
target: () => nodeIn(state, slot)?.node,
read: (node) => node.params[slot.key],
write: (node, value) => (node.params[slot.key] = value),
remove: (node) => delete node.params[slot.key],
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
},
);
};
}
/**
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
* something the node no longer reads.
*/
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
const port = inlineInputs(def).find((p) => p.key === key);
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
const inline = inlineInputDef(port);
return inline.kind === 'list' ? inline : null;
}
/** The node together with its graph, because `resolveDef` needs both. */
+6 -2
View File
@@ -29,14 +29,18 @@ 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.
*/
export type ListSlot =
export type ListSlot = { field?: string } & (
| { kind: 'asset'; assetId: string; key: string }
| { kind: 'object'; holderId: string; objectId: string; key: string }
| { kind: 'node'; assetId: string; nodeId: string; key: string };
| { kind: 'node'; assetId: string; nodeId: string; key: string }
);
export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
if (!a || !b) return a === b;
if (a.field !== b.field) return false;
switch (a.kind) {
case 'asset':
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;