A struct used to declare its fields wherever it stood. The same shape in two variables meant typing it twice, and Pack/Expand copied it once without staying in sync. A Struct asset now holds the shape in one place, and every struct that picks it as its type follows it. - New asset type "Struct" (not placeable, group Structs, own icon). The inspector shows its fields with name, kind and default value, and "Open" shows them in the variables tab. - Struct variables and struct fields get a Type row: own fields, any Struct asset, or "Make struct type". With a type the fields come from the asset and cannot be added, renamed or removed there. The values stay per variable, overrides on placed objects still work. - Pack and Expand get the setting Type. With a type their ports are the fields of the asset and read-only in the inspector. - A change to the asset reaches every typed place in one undo step (syncStructTypes). A new field arrives with its default. A removed field goes along with its port, edge and typed value. A renamed field keeps its value in variables, overrides and on ports with edges, also through nested types. - Undo for these edits records plain assignments (Recorder), because the reach of a change is not known in advance. - Cycles such as Loot in Loot, also through another type, are refused and not offered in the picker. - Deleting the asset names how many places use it. Afterwards each place keeps its fields as its own. - Loading reshapes a stale copy in a project file to its type. - "Make struct type" creates an asset from an existing struct and links the place to it. Differs from the plan: - A changed default value applies to new fields and newly typed places only. Existing values stay. - Removing a single field shows no warning. Undo brings it back. Typed lists are left for #254. Lists stay dynamic for now. Found by e2e: after deleting or renaming the type the inspector did not rebuild and kept showing the old name. Fixed, the check was red before. Tests: - ./check.sh: 182 files, 2661 tests, CHECK EXIT 0 - GM_STEPS=1,4,21,22,54: ALL PASSED, 176 checks - 14 unit tests in tests/core/struct-types.test.ts - New e2e module 54 with 14 checks, real mouse. Module 04 counts 23 asset types. - Counter-probes: without the rename pass 4 unit tests fail, without reshaping values 8, without the cycle rule 1. In the browser, without spreading changes and with + Field despite a type, 3 checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import {
|
||
AddPortCommand,
|
||
AddPortsCommand,
|
||
nextPortName,
|
||
RemovePortCommand,
|
||
RenamePortCommand,
|
||
RepointPortCommand,
|
||
SetPortWriteCommand,
|
||
} from '../../core/commands/portCommands';
|
||
import {
|
||
BEHAVIOR_START,
|
||
type DeclaredPort,
|
||
type GraphNode,
|
||
isPortOf,
|
||
isTerminalNode,
|
||
missingFieldPorts,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_BEHAVIOR_SETTING,
|
||
OBJECT_TYPE_SETTING,
|
||
portKindOf,
|
||
VARIABLES_SETTING,
|
||
variablesSourceOf,
|
||
propertyTargetOf,
|
||
structShapeOf,
|
||
targetLabel,
|
||
targetProperties,
|
||
} from '../../core/graph';
|
||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||
import {
|
||
Checkbox,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||
import { addNote, type InspectorHost } from './host';
|
||
import { kindMenu, markLabel } from './variableRows';
|
||
|
||
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
|
||
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
|
||
const source = variablesSourceOf(node.params);
|
||
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
|
||
const asset = findGraphAsset(project, assetId);
|
||
// A state machine declares no variables; its one node type has none to name.
|
||
return asset?.type === 'behavior' ? asset.variables : [];
|
||
}
|
||
|
||
/**
|
||
* The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
|
||
* rather than named, and its kind follows the declaration. Set makes it written too.
|
||
*/
|
||
export function buildVariablePortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
addSetting: (def: PropertyDef) => void,
|
||
): void {
|
||
addSetting(VARIABLES_SETTING);
|
||
const source = variablesSourceOf(node.params);
|
||
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Variable',
|
||
choices: variableChoices(host.state.project, assetId, node),
|
||
owner: shared ? `"${shared.name}"` : 'this behavior',
|
||
empty: source
|
||
? shared
|
||
? `"${shared.name}" declares no variables yet.`
|
||
: 'That Variables asset is gone.'
|
||
: 'This behavior has no variables yet.',
|
||
});
|
||
}
|
||
|
||
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
|
||
export function buildPropertyPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
addSetting: (def: PropertyDef) => void,
|
||
): void {
|
||
const target = propertyTargetOf(node.params);
|
||
addSetting(OBJECT_TYPE_SETTING);
|
||
if (target === 'any') {
|
||
buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true });
|
||
return;
|
||
}
|
||
if (target !== 'behavior') {
|
||
const owner = `a ${targetLabel(target)}`;
|
||
// A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot
|
||
// know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and a
|
||
// sprite property may share a name, and only the shader's entry earns the note.
|
||
const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : [];
|
||
const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')]));
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Property',
|
||
choices: [...targetProperties(target), ...shaderRows.map((row) => row.def)],
|
||
owner,
|
||
note: (def) => from.get(def),
|
||
});
|
||
return;
|
||
}
|
||
addSetting(OBJECT_BEHAVIOR_SETTING);
|
||
const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
|
||
const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined;
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Property',
|
||
choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
|
||
owner: behavior ? `"${behavior.name}"` : 'a behavior',
|
||
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
|
||
});
|
||
}
|
||
|
||
interface PickedPorts {
|
||
noun: 'Variable' | 'Property';
|
||
choices: readonly PropertyDef[];
|
||
/** Whose ports these are, for the warning on one it does not have. */
|
||
owner: string;
|
||
empty?: string;
|
||
/** Named by hand instead of picked. */
|
||
custom?: boolean;
|
||
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
|
||
note?: (def: PropertyDef) => string | undefined;
|
||
}
|
||
|
||
function buildPickedPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
{ noun, choices, owner, empty, custom = false, note }: PickedPorts,
|
||
): void {
|
||
const plural = noun === 'Property' ? 'properties' : 'variables';
|
||
const ports = node.ports ?? [];
|
||
const taken = new Set(ports.map((p) => p.key));
|
||
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
||
const choiceLabel = (def: PropertyDef): string => {
|
||
const where = note?.(def);
|
||
return where ? `${def.key} (${where})` : def.key;
|
||
};
|
||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||
choices.map((def) => ({
|
||
label: choiceLabel(def),
|
||
disabled: taken.has(def.key) && def.key !== own,
|
||
onSelect: () => onPick(portOf(def)),
|
||
}));
|
||
const typed = (): MenuItem[] =>
|
||
kindMenu(VARIABLE_KINDS, (kind) =>
|
||
host.state.execute(
|
||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||
),
|
||
);
|
||
const searchable = noun === 'Property';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
|
||
};
|
||
add.setEnabled(custom || choices.some((def) => !taken.has(def.key)));
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
if (!custom && choices.length === 0 && empty) addNote(host, empty);
|
||
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
|
||
|
||
for (const port of ports) {
|
||
const toggle = writeToggle(host, assetId, node, port);
|
||
if (custom) {
|
||
portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle);
|
||
continue;
|
||
}
|
||
const def = choices.find((d) => d.key === port.key);
|
||
const known = def !== undefined && isPortOf(port, def);
|
||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||
choose.onClick = () => {
|
||
const items = pick(port.key, (next) =>
|
||
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
|
||
);
|
||
new Menu(items, { searchable }).openBelow(choose);
|
||
};
|
||
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
|
||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* an *input* of the behavior – which is why the side comes from the node type, not from here.
|
||
*/
|
||
export function buildPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[] = [],
|
||
typeName: string | null = null,
|
||
): void {
|
||
const onStart = node.type === BEHAVIOR_START;
|
||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
|
||
if (typeName !== null) {
|
||
host.body.add(header);
|
||
addNote(host, `The fields of ${typeName}. Change them there.`, 'small');
|
||
for (const port of node.ports ?? []) {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(markLabel(port.kind));
|
||
row.add(new Label(port.key, { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }), 1);
|
||
host.body.add(row);
|
||
}
|
||
return;
|
||
}
|
||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = kindMenu(DECLARED_PORT_KINDS, (kind) =>
|
||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||
);
|
||
new Menu(items).openBelow(add);
|
||
};
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
const ports = node.ports ?? [];
|
||
if (ports.length === 0)
|
||
addNote(
|
||
host,
|
||
noun === 'Field'
|
||
? 'No fields yet. Every field added here becomes a port of the struct.'
|
||
: onStart
|
||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
|
||
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
|
||
);
|
||
|
||
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
|
||
}
|
||
|
||
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||
return new TextField(port.key, {
|
||
width: NAME_WIDTH,
|
||
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
|
||
});
|
||
}
|
||
|
||
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
|
||
const box = new Checkbox(port.write === true);
|
||
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
|
||
return [new Label('Set', { style: 'muted' }), box];
|
||
}
|
||
|
||
function portRow(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
port: DeclaredPort,
|
||
name: Widget,
|
||
extras: readonly Widget[] = [],
|
||
): void {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(markLabel(port.kind));
|
||
row.add(name, 1);
|
||
for (const extra of extras) row.add(extra);
|
||
row.add(new Spacer());
|
||
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
||
host.body.add(row);
|
||
}
|
||
|
||
/**
|
||
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
|
||
* typing them again. Only offered where there is a shape to copy; a field the node already has
|
||
* stays as it is, so this adds and never rewrites.
|
||
*/
|
||
function addShapePicker(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[],
|
||
header: HStack,
|
||
): void {
|
||
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
|
||
if (shaped.length === 0) return;
|
||
|
||
const pick = new Dropdown('From variable', { variant: 'ghost' });
|
||
pick.onClick = () => {
|
||
const items: MenuItem[] = shaped.map((v) => {
|
||
const ports = missingFieldPorts(node, structShapeOf(v) ?? []);
|
||
return {
|
||
label: v.kind === 'list' ? `${v.key} (item)` : v.key,
|
||
disabled: ports.length === 0,
|
||
onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)),
|
||
};
|
||
});
|
||
new Menu(items).openBelow(pick);
|
||
};
|
||
header.add(pick);
|
||
}
|