Let a variable declare struct fields

The value type was already there: StructValue, textOf, sameValue,
writeTyped/mergeStruct and the struct port type came with the first
part, and Get/Set variable carry any-ports, so a bundle already flowed
through the graph unchanged. What was missing was the declaration.

PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a
fifth kind, variableDef a fifth parameter. A declared field is a
primitive - a list field would need a nested editor and therefore the
path in the slot address, the same step "a list of structs" is waiting
for. coerceValue now returns PropertyValue rather than FieldValue, the
first value type that is not a field; none of its five callers break.

In the inspector a struct variable is a block, not a tab. The list tab
exists because hundreds of rows fit in no inspector row; a struct is the
opposite case - few fields that fit under each other. It is the port
editor Pack already has, one level down and with a value column: type,
key and default value in one place. A placed object sees the same fields
as plain value rows: overriding yes, declaring no.

Adding or removing a field touches the declaration, the default, the
behavior's value and every instance override in one command, so a stored
value can never keep a shape its declaration no longer promises. The row
handlers read the bundle at the moment of the change rather than closing
over a snapshot - a field writes the whole bundle, so with a snapshot
the second edited row revives the first one's old value. defSignature
carries the fields, otherwise a new row appears only at the next
rebuild.

Fixes a fixture fault this uncovered: step 10 pushed its text object
into the scene without instanceEdits, which overrideSites walks with
Object.entries. AddStructFieldCommand is the first caller to reach it
from that state; the step was green alone and failed three checks in the
full suite.

Moves the dynamic-ports note to done - all three of its parts are built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 12:29:01 +02:00
1 parent 4d07946c74
commit 0449218cb6
17 files changed
+1146 -69

No files matched your search

+8 -21
View File
@@ -3,16 +3,13 @@
* can be hundreds long has no room in an inspector row. The inspector stays visible beside this
* tab, so what the list belongs to is still on screen while it is edited.
*/
import type { ListItemKind, PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { createField, type FieldContext } from '../inspector/fieldFactory';
import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory';
import type { EditorState } from '../state';
import { type ListAccess, resolveList } from './slot';
const INDEX_WIDTH = 40;
type ItemDef = Extract<PropertyDef, { kind: ListItemKind }>;
export class ListEditor extends VStack {
private readonly ctx: FieldContext;
private readonly title = new Label('', { style: 'bodyBold' });
@@ -118,7 +115,12 @@ export class ListEditor extends VStack {
const label = new Label(String(index), { style: 'small', align: 'right' });
label.minWidth = INDEX_WIDTH;
row.add(label);
const field = createField(this.ctx, itemDef(access.def.items, index), item, access.itemHandlers(index));
const field = createField(
this.ctx,
primitiveDef(String(index), access.def.items),
item,
access.itemHandlers(index),
);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
@@ -135,21 +137,6 @@ export class ListEditor extends VStack {
private addItem(): void {
const access = this.access();
if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]);
}
}
/** One item as a property definition, so the inspector's own widget renders it. */
function itemDef(items: ListItemKind, index: number): ItemDef {
const key = String(index);
switch (items) {
case 'number':
return { key, label: key, kind: 'number', default: 0 };
case 'text':
return { key, label: key, kind: 'text', default: '' };
case 'boolean':
return { key, label: key, kind: 'boolean', default: false };
case 'color':
return { key, label: key, kind: 'color', default: 0 };
if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]);
}
}