Files
feuerwerk/src/editor/inspector/assetSection.ts
T
rootandClaude Opus 5 0449218cb6 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>
2026-09-10 12:29:01 +02:00

330 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveStructFieldCommand,
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
} 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 {
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
schemaForAsset,
type StructDef,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { aspectActions, sizeCommand } from './aspectAction';
import {
createField,
createFieldRow,
type FieldHandlers,
labelledRow,
primitiveDef,
structFieldValue,
} from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import {
listOpener,
type PreviewedValue,
previewingHandlers,
structFields,
structFieldsOf,
} from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
list: 'List',
struct: 'Struct',
};
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
list: '≡',
struct: '⊞',
};
const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
};
const FIELD_INDENT = 12;
const FIELD_KIND_MARKS: Record<ListItemKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
};
/**
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a
* struct – those are rows of their own, so adding one has to rebuild the section.
*/
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
return asset ? schemaForAsset(asset).map(defSignature).join(',') : 'missing';
}
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})` : ''}`;
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
const asset = findAsset(host.state.project, assetId);
if (!asset) return;
addTitle(host, `${asset.type.toUpperCase()} ASSET`);
host.nameField = new TextField(asset.name, {
onChange: (name) => {
if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim()));
else host.nameField?.setValue(asset.name);
},
});
const { row } = labelledRow('Name');
row.add(host.nameField, 1);
host.body.add(row);
buildProps(host, asset, markedAssetSchema(asset));
if (asset.type === 'behavior') buildVariables(host, asset);
}
/** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */
export function refreshAssetSection(host: InspectorHost, assetId: string): boolean {
const asset = findAsset(host.state.project, assetId);
if (!asset) return false;
host.nameField?.setValue(asset.name);
const values = resolveAssetProps(asset);
for (const [key, row] of host.fields) {
row.setValue(values[key] ?? 0);
row.setModified(isModified(asset, key));
}
return true;
}
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
{
extra: aspectActions(
host,
asset,
def,
() => {
const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(target) : null;
},
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
),
),
reset: () => {
const target = findAsset(host.state.project, asset.id);
if (!target || !isModified(target, def.key)) return [];
return [
{
label: 'Reset to default',
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
},
];
},
},
);
row.setModified(isModified(asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
}
}
/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */
function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(host.state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetSpec(host, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
...structFields<Asset>(host, def, spec),
};
}
/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */
function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
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 = VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
}));
new Menu(items).openBelow(add);
};
header.add(add);
host.body.add(header);
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
const values = resolveAssetProps(asset);
for (const def of asset.variables) {
if (def.kind === 'struct') {
buildStructVariable(host, asset, def, values[def.key] ?? def.default);
continue;
}
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(
new TextField(def.key, {
width: 90,
onChange: (next) => {
host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()));
},
}),
);
const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
}
}
/**
* 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,
* one level down and with a value column – a field is declared and given a default in one place.
*/
function buildStructVariable(
host: InspectorHost,
asset: BehaviorAsset,
def: StructDef,
value: PropertyValue,
): void {
const head = new HStack({ gap: Theme.gap, align: 'center' });
head.minHeight = Theme.rowHeight;
const mark = new Label(VARIABLE_KIND_MARKS.struct ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
head.add(mark);
head.add(
new TextField(def.key, {
width: 90,
onChange: (next) => host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())),
}),
);
head.add(new Spacer(), 1);
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
const items = LIST_ITEM_KINDS.map((kind) => ({
label: FIELD_KIND_LABELS[kind],
onSelect: () =>
host.state.execute(
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
),
}));
new Menu(items).openBelow(add);
};
head.add(add);
head.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
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 fields = new Map<string, (item: PropertyValue) => 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 fieldMark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
fieldMark.minWidth = 18;
row.add(fieldMark);
row.add(
new TextField(field.key, {
width: 90,
onChange: (next) =>
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),
);
row.add(item.widget, item.grow ? 1 : 0);
if (!item.grow) 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)),
}),
);
fields.set(field.key, item.setValue);
host.body.add(row);
}
host.fields.set(def.key, {
row: head,
setValue: (next) => {
for (const field of def.fields) fields.get(field.key)?.(structFieldValue(next, field));
},
setModified: () => {},
});
}