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

+171 -42
View File
@@ -1,6 +1,8 @@
import {
type FieldValue,
isList,
isStruct,
type ListValue,
type PropertyBag,
type PropertyValue,
type StructValue,
@@ -8,10 +10,13 @@ import {
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import {
emptyField,
coerceValue,
emptyFieldValue,
isCommonProp,
type ListItemKind,
type PrimitiveFieldDef,
primitiveFieldsOf,
type PropertyDef,
type StructDef,
type StructFieldDef,
type VariableKind,
variableDef,
@@ -151,16 +156,36 @@ export function isValidFieldName(name: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
function requireStruct(project: Project, assetId: string, key: string): StructDef {
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
interface FieldOwner {
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
readonly inList: boolean;
fields(): readonly StructFieldDef[];
setFields(next: readonly StructFieldDef[]): void;
}
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
if (!def || def.kind !== 'struct') throw new CommandError(`Variable "${key}" is not a struct`);
return def;
if (def?.kind === 'struct')
return {
inList: false,
fields: () => def.fields,
setFields: (next) => (def.fields = next),
};
if (def?.kind === 'list' && def.items === 'struct')
return {
inList: true,
fields: () => def.fields ?? [],
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
};
throw new CommandError(`Variable "${key}" has no fields`);
}
/**
* Every value of one struct variable: the declared default, the behavior's own, and each instance
* override. A field is added to or taken from all of them at once, so no value keeps a shape its
* declaration no longer promises. The `id` is what pairs a site up again when the change is undone.
* Every value of one variable that carries a struct: the declared default, the behavior's own, and
* each instance override – and for a list of structs, every item of each of those. A field is added
* to or taken from all of them at once, so no value keeps a shape its declaration no longer
* promises. The `id` is what pairs a site up again when the change is undone.
*/
interface StructSite {
id: string;
@@ -169,35 +194,68 @@ interface StructSite {
}
function structSites(project: Project, assetId: string, key: string): StructSite[] {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
const holders = valueHolders(project, assetId, key).filter(
// An override the object does not have stays absent: writing one here would invent a deviation.
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
);
const sites: StructSite[] = [];
for (const holder of holders) {
if (def?.kind !== 'list') {
sites.push({
id: holder.id,
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
write: (value) => holder.write(value),
});
continue;
}
const list = holder.read();
if (!isList(list)) continue;
for (let index = 0; index < list.length; index++) {
sites.push({
id: `${holder.id}#${index}`,
read: () => {
const item = (holder.read() as ListValue)[index];
return isStruct(item) ? item : {};
},
write: (value) =>
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
});
}
}
return sites;
}
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
interface ValueHolder {
id: string;
/** An override may simply not be there; the declared default and the behavior's own always are. */
optional: boolean;
read(): PropertyValue | undefined;
write(value: PropertyValue): void;
}
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
const asset = requireBehavior(project, assetId);
const def = asset.variables.find((v) => v.key === key);
const sites: StructSite[] = [];
if (def?.kind === 'struct')
sites.push({
const holders: ValueHolder[] = [];
if (def)
holders.push({
id: 'declared',
optional: false,
read: () => def.default,
write: (value) => {
def.default = value;
},
});
sites.push(bagSite('asset', asset.props, key));
holders.push(bagHolder('asset', false, asset.props, key));
for (const site of overrideSites(project, (id) => id === assetId))
// An override the object does not have stays absent: writing one here would invent a deviation.
if (isStruct(site.bag[key])) sites.push(bagSite(`${site.object.id}/${site.path}`, site.bag, key));
return sites;
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
return holders;
}
function bagSite(id: string, bag: PropertyBag, key: string): StructSite {
return {
id,
read: () => {
const value = bag[key];
return isStruct(value) ? value : {};
},
write: (value) => {
bag[key] = value;
},
};
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
}
function withoutField(value: StructValue, key: string): StructValue {
@@ -225,18 +283,20 @@ export class AddStructFieldCommand implements Command {
}
execute(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
if (def.fields.some((f) => f.key === this.field.key))
if (owner.fields().some((f) => f.key === this.field.key))
throw new CommandError(`Field "${this.field.key}" already exists`);
def.fields = [...def.fields, { ...this.field }];
if (owner.inList && this.field.kind === 'list')
throw new CommandError('A list field cannot sit in the items of a list');
owner.setFields([...owner.fields(), { ...this.field }]);
for (const site of structSites(project, this.assetId, this.key))
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
}
undo(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
def.fields = def.fields.filter((f) => f.key !== this.field.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
for (const site of structSites(project, this.assetId, this.key))
site.write(withoutField(site.read(), this.field.key));
}
@@ -256,8 +316,8 @@ export class RemoveStructFieldCommand implements Command {
}
execute(project: Project): void {
const def = requireStruct(project, this.assetId, this.key);
const fields = [...def.fields];
const owner = requireFieldOwner(project, this.assetId, this.key);
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<string, FieldValue>();
@@ -267,16 +327,16 @@ export class RemoveStructFieldCommand implements Command {
if (item !== undefined) values.set(site.id, item);
site.write(withoutField(value, this.field));
}
def.fields = fields;
owner.setFields(fields);
this.removed = { field, values };
}
undo(project: Project): void {
if (!this.removed) return;
const def = requireStruct(project, this.assetId, this.key);
const fields = [...def.fields];
const owner = requireFieldOwner(project, this.assetId, this.key);
const fields = [...owner.fields()];
restoreAt(fields, [this.removed.field]);
def.fields = fields;
owner.setFields(fields);
for (const site of structSites(project, this.assetId, this.key)) {
const value = this.removed.values.get(site.id);
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
@@ -305,13 +365,82 @@ export class RenameStructFieldCommand implements Command {
}
private rename(project: Project, from: string, to: string): void {
const def = requireStruct(project, this.assetId, this.key);
const owner = requireFieldOwner(project, this.assetId, this.key);
if (from === to) return;
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
if (!def.fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
if (def.fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
def.fields = def.fields.map((f) => (f.key === from ? { ...f, key: to } : f));
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))
site.write(renameField(site.read(), from, to));
}
}
/**
* Changes what a list variable holds. The declaration is the only place that says it, so every
* value has to follow at once – each is run through `coerceValue` against the new declaration,
* which is the same conversion an item edited in the List tab goes through.
*/
export class SetListItemsCommand implements Command {
readonly label: string;
private before?: {
items: ListItemKind;
fields?: readonly PrimitiveFieldDef[];
values: Map<string, PropertyValue>;
};
constructor(
private readonly assetId: string,
private readonly key: string,
private readonly items: ListItemKind,
) {
this.label = `Set ${key} items`;
}
execute(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
if (def.items === this.items)
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
const values = new Map<string, PropertyValue>();
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) values.set(holder.id, value);
}
this.before = { items: def.items, fields: def.fields, values };
def.items = this.items;
// Fields belong to struct items alone: kept while leaving so an undo has them back.
if (this.items === 'struct' && !def.fields) def.fields = [];
this.recoerce(project);
}
undo(project: Project): void {
if (!this.before) return;
const def = requireListDef(project, this.assetId, this.key);
def.items = this.before.items;
if (this.before.fields) def.fields = this.before.fields;
else delete def.fields;
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = this.before.values.get(holder.id);
if (value !== undefined) holder.write(value);
}
}
private recoerce(project: Project): void {
const def = requireListDef(project, this.assetId, this.key);
for (const holder of valueHolders(project, this.assetId, this.key)) {
const value = holder.read();
if (value !== undefined) holder.write(coerceValue(def, value));
}
}
}
function requireListDef(
project: Project,
assetId: string,
key: string,
): Extract<PropertyDef, { kind: 'list' }> {
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
return def;
}
+11 -1
View File
@@ -3,7 +3,7 @@ import type { PropertyDef } from '../properties/PropertySchema';
import type { Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asNumber, asString, type Value } from './values';
import { asBoolean, asList, asNumber, asString, type Value } from './values';
export interface PortDef {
key: string;
@@ -135,6 +135,16 @@ export function inlineInputDef(port: PortDef): PropertyDef {
case 'string':
case 'any':
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
// No port declares an item kind, so an inline list reads as numbers – the same default
// `+ Variable ▸ List` picks. The graph types a list as a list either way.
case 'list':
return {
key: port.key,
label: port.label,
kind: 'list',
default: asList(port.default),
items: 'number',
};
default:
return {
key: port.key,
+38 -6
View File
@@ -56,7 +56,7 @@ export type PropertyDef =
default: ListValue;
items: ListItemKind;
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
fields?: readonly StructFieldDef[];
fields?: readonly PrimitiveFieldDef[];
}
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
@@ -64,19 +64,37 @@ export type PropertyDef =
*/
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
export interface StructFieldDef {
/** What a field of a struct property holds – a primitive plus `list`. */
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
}
/**
* A list held by a struct field. Its items are primitives, and that asymmetry is the whole point:
* a list may hold structs and a struct may hold lists, so only forbidding one of the two here
* keeps the storable shapes finite. `list → struct → primitive` and `struct → list → primitive`
* are the two deepest, which is exactly what `asStoredValue` lets through.
*/
export interface ListFieldDef {
key: string;
kind: 'list';
items: FieldKind;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
@@ -214,7 +232,7 @@ export function variableDef(
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? (fields ?? []) : undefined;
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
@@ -232,6 +250,10 @@ export function variableDef(
}
}
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
@@ -244,6 +266,10 @@ export function emptyField(kind: FieldKind): number | string | boolean {
return EMPTY_FIELD[kind];
}
export function emptyFieldValue(field: StructFieldDef): FieldValue {
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
}
/**
* A struct over exactly the declared fields: a declared field missing from `from` starts empty
* rather than absent, so a struct value always has the shape its declaration promises.
@@ -253,7 +279,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
const value: Record<string, FieldValue> = {};
for (const field of fields)
value[field.key] =
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
field.key in source ? coerceFieldValue(source[field.key]!, field) : emptyFieldValue(field);
return value;
}
@@ -298,6 +324,12 @@ export function coerceListItem(
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
}
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind !== 'list') return coerceField(value, field.kind);
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
}
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value);
+10 -2
View File
@@ -194,13 +194,21 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
const f = asRecord(item);
const key = typeof f.key === 'string' ? f.key : '';
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
const field = readField(key, f);
if (!field) continue;
seen.add(key);
fields.push({ key, kind: f.kind as FieldKind });
fields.push(field);
}
return fields;
}
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
if (f.kind === 'list')
return { key, kind: 'list', items: isFieldKind(f.items) ? (f.items as FieldKind) : 'number' };
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
}
function normalizePorts(raw: unknown): DeclaredPort[] {
const ports: DeclaredPort[] = [];
const seen = new Set<string>();
+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;