Let a list hold structs, and read or write variables in bundles

A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 14:01:56 +02:00
1 parent 9d62dfea7b
commit f60358c951
32 files changed
+1083 -253

No files matched your search

+1 -1
View File
@@ -226,7 +226,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
list: {
info: () => editor.listEditor.debugInfo(),
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
fieldAt: (index: number) => editor.listEditor.fieldAt(index),
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
},
world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(),
+5 -5
View File
@@ -16,8 +16,8 @@ import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import {
LIST_ITEM_KINDS,
type ListItemKind,
FIELD_KINDS,
type FieldKind,
type PropertyDef,
schemaForAsset,
type StructDef,
@@ -62,7 +62,7 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
struct: '⊞',
};
const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
@@ -71,7 +71,7 @@ const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
const FIELD_INDENT = 12;
const FIELD_KIND_MARKS: Record<ListItemKind, string> = {
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
@@ -264,7 +264,7 @@ function buildStructVariable(
head.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
const items = LIST_ITEM_KINDS.map((kind) => ({
const items = FIELD_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
+6 -3
View File
@@ -8,7 +8,7 @@ import {
} from '../../core/properties/assetRef';
import {
emptyField,
type ListItemKind,
type FieldKind,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
@@ -179,10 +179,12 @@ interface Field {
/** Whether the widget should take the remaining row width. */
grow: boolean;
setValue(value: PropertyValue): void;
/** The per-field widgets of a struct, so a caller can address one of them. */
parts?: ReadonlyMap<string, Widget>;
}
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
export function primitiveDef(key: string, kind: ListItemKind, label = key): PrimitiveDef {
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) {
case 'number':
return { key, label, kind: 'number', default: 0 };
@@ -195,7 +197,7 @@ export function primitiveDef(key: string, kind: ListItemKind, label = key): Prim
}
}
export type PrimitiveDef = Extract<PropertyDef, { kind: ListItemKind }>;
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
/** 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 {
@@ -265,6 +267,7 @@ export function createField(
setValue: (v) => {
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
},
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
};
}
case 'boolean': {
+4 -2
View File
@@ -1,6 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
callProblem,
declaresVariablePorts,
type Graph,
edgesInto,
type GraphNode,
@@ -16,7 +17,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection } from './portSection';
import { buildPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
@@ -51,7 +52,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
addNote(host, def.description);
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
if (def.declaresPorts) buildPortSection(host, assetId, node);
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
else if (def.declaresPorts) buildPortSection(host, assetId, node);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
+73 -1
View File
@@ -3,15 +3,18 @@ import {
nextPortName,
RemovePortCommand,
RenamePortCommand,
SetVariablePortCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type GraphNode,
isTerminalNode,
} from '../../core/graph';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui';
import { addNote, type InspectorHost } from './host';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
@@ -32,6 +35,75 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
struct: '⊞',
};
/**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
* from that list rather than named, and its kind follows the variable's declaration.
*/
export function buildVariablePortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[],
): void {
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) });
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
variables.map((v) => ({
label: v.key,
disabled: taken.has(v.key) && v.key !== own,
onSelect: () => onPick(portOf(v)),
}));
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(items).openBelow(add);
};
add.setEnabled(variables.length > taken.size);
header.add(add);
host.body.add(header);
if (variables.length === 0) addNote(host, 'This behavior has no variables yet.');
else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.');
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)),
);
new Menu(items).openBelow(choose);
};
row.add(choose, 1);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
/** Every variable kind is a port kind too; the fallback is for a project file that says otherwise. */
function variablePortKind(def: PropertyDef): DeclaredPortKind {
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
? (def.kind as DeclaredPortKind)
: 'text';
}
/**
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
+2 -2
View File
@@ -1,5 +1,5 @@
import {
coerceListItem,
coerceField,
type PropertyDef,
type StructDef,
type StructFieldDef,
@@ -45,7 +45,7 @@ export function structFieldsOf<Target>(
...spec,
toModel: (input, target) => ({
...structOf(def.fields, spec.read(target)),
[field.key]: coerceListItem(input, field.kind),
[field.key]: coerceField(input, field.kind),
}),
});
}
+31 -20
View File
@@ -4,9 +4,11 @@
* tab, so what the list belongs to is still on screen while it is edited.
*/
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory';
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
import { type ListAccess, type ListDef, resolveList } from './slot';
const INDEX_WIDTH = 40;
@@ -19,8 +21,8 @@ export class ListEditor extends VStack {
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
private setters: ((value: number | string | boolean) => void)[] = [];
private fields: Widget[] = [];
private setters: ((value: PropertyValue) => void)[] = [];
private fields: { widget: Widget; parts?: ReadonlyMap<string, Widget> }[] = [];
constructor(private readonly state: EditorState) {
super({ gap: 0 });
@@ -48,8 +50,9 @@ export class ListEditor extends VStack {
}
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number): { x: number; y: number } | null {
const widget = this.fields[index];
fieldAt(index: number, field = ''): { x: number; y: number } | null {
const found = this.fields[index];
const widget = field ? found?.parts?.get(field) : found?.widget;
if (!widget) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -61,7 +64,7 @@ export class ListEditor extends VStack {
if (!access) return { title: '', items: [], kind: '' };
return {
title: access.title,
items: access.value.map((item) => String(item)),
items: access.value.map((item) => textOf(item)),
kind: access.def.items,
};
}
@@ -71,9 +74,11 @@ export class ListEditor extends VStack {
return slot ? resolveList(this.state, slot) : null;
}
/** Everything that decides how many rows there are and of what kind. */
/** Everything that decides how many rows there are and of what kind – a struct's fields included. */
private signatureOf(access: ListAccess | null): string {
return access ? `${access.title}|${access.def.items}|${access.value.length}` : '';
if (!access) return '';
const fields = (access.def.fields ?? []).map((f) => `${f.key}:${f.kind}`).join(',');
return `${access.title}|${access.def.items}|${fields}|${access.value.length}`;
}
private refresh(): void {
@@ -109,18 +114,16 @@ export class ListEditor extends VStack {
this.relayout();
}
private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
const tall = access.def.items === 'struct';
const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' });
if (!tall) row.minHeight = Theme.rowHeight;
const label = new Label(String(index), { style: 'small', align: 'right' });
label.minWidth = INDEX_WIDTH;
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const field = createField(
this.ctx,
primitiveDef(String(index), access.def.items),
item,
access.itemHandlers(index),
);
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
@@ -131,12 +134,20 @@ export class ListEditor extends VStack {
}),
);
this.setters.push(field.setValue);
this.fields.push(field.widget);
this.fields.push({ widget: field.widget, parts: field.parts });
return row;
}
private addItem(): void {
const access = this.access();
if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]);
if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]);
}
}
/** Item `index` as a definition, so the inspector's own widgets render a row of the list. */
function itemDef(def: ListDef, index: number): PrimitiveDef | StructDef {
const key = String(index);
return def.items === 'struct'
? { key, label: key, kind: 'struct', fields: def.fields ?? [], default: structOf(def.fields ?? [], {}) }
: primitiveDef(key, def.items);
}
+34 -5
View File
@@ -12,13 +12,16 @@ import { resolveDef } from '../../core/graph/nested';
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 } from '../../core/model/Properties';
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import {
coerceField,
coerceListItem,
type PropertyDef,
type PropertySchema,
schemaForAsset,
structOf,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
@@ -53,9 +56,13 @@ const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
return def?.kind === 'list' ? def : null;
};
const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue =>
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
list.map((value, i) => (i === index ? item : value));
/** The item at `index` in the shape the declaration promises, so a field write never invents one. */
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
structOf(def.fields ?? [], list[index]);
/**
* `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
@@ -72,11 +79,33 @@ function access<Target>(
def,
title,
value: current(),
itemHandlers: (index) =>
previewingHandlers<Target>(
itemHandlers: (index) => ({
...previewingHandlers<Target>(
{ state },
{ ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) },
{
...spec,
toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)),
},
),
// 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.
...(def.items === 'struct'
? {
structFieldHandlers: (field: StructFieldDef) =>
previewingHandlers<Target>(
{ state },
{
...spec,
toModel: (input) =>
withItem(current(), index, {
...structItemAt(current(), index, def),
[field.key]: coerceField(input, field.kind),
}),
},
),
}
: {}),
}),
// 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)),