A state machine is an asset whose graph holds one `State` node per state (no ports, name as title), edited in a new "State machine" tab that reuses the graph canvas: right-click places "State n", snap toggle shared with the Behavior tab via SnapToggle. `statemachine` is a value kind like texture (port, literal node, variable, struct field, tagged list item). Behaviors use it through `State branch` (one exec output per state) and `Set state` (one exec input per state; the interpreter now hands `run` the entry port). States live per behavior instance in NodeContext.states, with no initial state. Ports derive from the inline picker or a wired `State machine` literal; BehaviorLookup became GraphLookup with behavior(id) and machine(id). The Graph tab is labelled "Behavior" (key unchanged). Unit tests (state-machine, kinds, catalog, ports), e2e step 26 and README added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
130 lines
5.4 KiB
TypeScript
130 lines
5.4 KiB
TypeScript
import {
|
|
callProblem,
|
|
declaresPropertyPorts,
|
|
declaresVariablePorts,
|
|
edgesInto,
|
|
type GraphNode,
|
|
inlineInputDef,
|
|
inlineInputs,
|
|
OBJECT_BEHAVIOR_PARAM,
|
|
OBJECT_TYPE_PARAM,
|
|
portSignature,
|
|
resolveDef,
|
|
} from '../../core/graph';
|
|
import type { GraphAsset } from '../../core/model/Asset';
|
|
import { graphLookup } from '../../core/model/graphLookup';
|
|
import { findGraphAsset, type Project } from '../../core/model/Project';
|
|
import { textOf } from '../../core/model/Properties';
|
|
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
|
import { Label } from '../../ui';
|
|
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
|
import { addNote, addTitle, type InspectorHost } from './host';
|
|
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
|
|
import { listOpener, previewingHandlers } from './previewHandlers';
|
|
import { currentNode, nodeParamValue } from './valueSpecs';
|
|
|
|
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
|
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
|
const asset = findGraphAsset(project, assetId);
|
|
if (!asset) return 'missing';
|
|
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
|
if (!node) return 'missing';
|
|
const lookup = graphLookup(project);
|
|
const def = resolveDef(node, asset.graph, lookup);
|
|
const connected = def
|
|
? inlineInputs(def)
|
|
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
|
.map((p) => p.key)
|
|
: [];
|
|
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
|
|
// They decide which rows the inspector builds, and two behaviors may share a name in the title.
|
|
const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`;
|
|
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
|
|
asset,
|
|
)
|
|
.map((v) => v.key)
|
|
.join(',')}|${target}`;
|
|
}
|
|
|
|
/** A state machine declares no variables; its one node type has none to name. */
|
|
const variablesOf = (asset: GraphAsset): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []);
|
|
|
|
/** Description, settings (params) and the inline values of unconnected data inputs. */
|
|
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
|
|
const asset = findGraphAsset(host.state.project, assetId);
|
|
if (!asset) return;
|
|
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
|
const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project));
|
|
if (!node || !def) return;
|
|
|
|
addTitle(host, `${def.category.toUpperCase()} NODE`);
|
|
host.body.add(new Label(def.label, { style: 'bodyBold' }));
|
|
addNote(host, def.description);
|
|
const problem = callProblem(node, graphLookup(host.state.project), assetId);
|
|
if (problem) addNote(host, problem, 'warning');
|
|
const addSetting = (param: PropertyDef) => {
|
|
const row = createFieldRow(
|
|
host,
|
|
param,
|
|
node.params[param.key] ?? param.default,
|
|
paramHandlers(host, assetId, nodeId, param),
|
|
);
|
|
host.fields.set(`param:${param.key}`, row);
|
|
host.body.add(row.row);
|
|
};
|
|
const ownSettings = declaresPropertyPorts(node.type);
|
|
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, variablesOf(asset));
|
|
else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting);
|
|
else if (def.declaresPorts) buildPortSection(host, assetId, node, variablesOf(asset));
|
|
|
|
if (!ownSettings && def.params.length > 0) {
|
|
addTitle(host, 'SETTINGS');
|
|
for (const param of def.params) addSetting(param);
|
|
}
|
|
|
|
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
|
|
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
|
|
for (const port of free) {
|
|
const inline = inlineInputDef(port);
|
|
const row = createFieldRow(
|
|
host,
|
|
inline,
|
|
node.params[port.key] ?? inline.default,
|
|
paramHandlers(host, assetId, nodeId, inline),
|
|
);
|
|
host.fields.set(`input:${port.key}`, row);
|
|
host.body.add(row.row);
|
|
}
|
|
}
|
|
|
|
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
|
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
|
|
const found = currentNode(host.state.project, assetId, nodeId);
|
|
const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project));
|
|
if (!found || !def) return false;
|
|
for (const [key, row] of host.fields) {
|
|
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
|
const port = def.inputs.find((p) => p.key === name);
|
|
const fallback =
|
|
scope === 'param'
|
|
? def.params.find((p) => p.key === name)?.default
|
|
: port && inlineInputDef(port).default;
|
|
row.setValue(found.node.params[name] ?? fallback ?? '');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Previews straight into the node's params; the command records the value from before the preview. */
|
|
function paramHandlers(
|
|
host: InspectorHost,
|
|
assetId: string,
|
|
nodeId: string,
|
|
def: PropertyDef,
|
|
): FieldHandlers {
|
|
const key = def.key;
|
|
return {
|
|
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
|
|
...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })),
|
|
};
|
|
}
|