Split a struct output into one pin per field (#256)
Reading one field of a struct needed its own Expand node, even where the graph already knows the struct type. A split output shows the fields directly on the node, so a Find … Match test becomes a single Equals and the wires keep running left to right. - Right-clicking an output pin offers "Split into <Type> fields" when the type is known and "Split as …" for every other struct asset. The output is replaced by one pin per field with the field's kind. - The type is known for typed struct variables, typed Pack/Expand, fields of another split and item of For each, Find, Match and Item at over a typed list (portSplits.ts). - Right-clicking a field pin offers "Recombine". Splitting drops the edges of the whole output, recombining drops the field edges. One undo step brings them back. - The split is stored as GraphNode.splits and saved with the graph. Copy and paste carry it along. - Field pins follow the struct asset like a typed Expand. A new field gets a pin, a removed field loses pin and edge, a renamed field keeps its edge. Deleting the asset keeps the last fields. The delete dialog counts splits, so it now says "node(s)" instead of "Pack/Expand node(s)". - Renaming a variable carries its split and the field edges along. - At run time the node still produces the whole value. A field pin reads its field like Expand, a missing field reads as empty. - Limits: outputs only, one level, behavior graphs only. Tests: - ./check.sh: 185 files, 2706 tests, CHECK EXIT 0 - e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks - New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks using a real mouse: split Find.item, wire item.Name into the test and Match.item.Amount into the log (logs 7), recombine. - Counter-checks: without field reads in the interpreter, without rename in the split, without moving edges on rename and without cleanup on field removal one unit test fails each. In the browser, without field reads nothing is logged and without type inference the split entry is missing. - Check total in README: 1520. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2aaca01929
commit
38e0f656be
17 files changed
+705
-22
No files matched your search
@@ -5,10 +5,26 @@
|
||||
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
|
||||
*
|
||||
* Items declare nothing, so a struct item carries its type as a mark (#254): every item of a typed
|
||||
* list, and any struct item given one in a mixed list. The same sync keeps marked items in shape.
|
||||
* list, and any struct item given one in a mixed list. The same sync keeps marked items in shape,
|
||||
* and the field ports of split outputs, which keep a copy of their type's fields like `Expand` does.
|
||||
*/
|
||||
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
|
||||
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
|
||||
import { FIND, findsOf, MATCH } from '../graph/findBlock';
|
||||
import {
|
||||
type DeclaredPort,
|
||||
edgesInto,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
type PortSplit,
|
||||
} from '../graph/Graph';
|
||||
import {
|
||||
fixedPortKeys,
|
||||
isValidPortName,
|
||||
STRUCT_TYPE_PARAM,
|
||||
VARIABLES,
|
||||
variablesSourceOf,
|
||||
} from '../graph/nested';
|
||||
import { splitField, splitPortKey } from '../graph/portSplits';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import {
|
||||
@@ -35,7 +51,7 @@ import {
|
||||
textOf,
|
||||
withItemType,
|
||||
} from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
|
||||
export const PACK_STRUCT = 'PackStruct';
|
||||
export const EXPAND_STRUCT = 'ExpandStruct';
|
||||
@@ -384,6 +400,7 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
|
||||
if (value !== undefined) holder.write(renamedItems(project, value, rename));
|
||||
}
|
||||
if (rename.path.length > 0) return;
|
||||
renameSplitEdges(project, rec, rename);
|
||||
for (const { graph, node } of typedNodes(project)) {
|
||||
if (nodeStructType(node) !== rename.structType) continue;
|
||||
const ports = node.ports ?? [];
|
||||
@@ -405,6 +422,50 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Every split output in the project, with the node and graph it sits in. */
|
||||
function splitOutputs(project: Project): { graph: Graph; node: GraphNode; split: PortSplit }[] {
|
||||
return project.assets.flatMap((asset) =>
|
||||
isGraphAsset(asset)
|
||||
? asset.graph.nodes.flatMap((node) =>
|
||||
(node.splits ?? []).map((split) => ({ graph: asset.graph, node, split })),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** A renamed field's edges move to its new field port, before the sync would drop them as gone. */
|
||||
function renameSplitEdges(project: Project, rec: Recorder, { structType, from, to }: FieldRename): void {
|
||||
for (const { graph, node, split } of splitOutputs(project)) {
|
||||
if (split.structType !== structType) continue;
|
||||
const old = splitPortKey(split.port, from);
|
||||
for (const edge of graph.edges)
|
||||
if (edge.from.node === node.id && edge.from.port === old)
|
||||
rec.set(edge.from, 'port', splitPortKey(split.port, to));
|
||||
}
|
||||
}
|
||||
|
||||
/** A split's fields follow its type; an edge on a field that is gone goes with it. */
|
||||
function syncSplit(graph: Graph, node: GraphNode, split: PortSplit, type: StructAsset, rec: Recorder): void {
|
||||
const fields: DeclaredPort[] = typeFields(type)
|
||||
.filter((f) => isValidPortName(f.key))
|
||||
.map((f) => ({ key: f.key, kind: f.kind }));
|
||||
if (JSON.stringify(fields) === JSON.stringify(split.fields)) return;
|
||||
rec.set(
|
||||
node,
|
||||
'splits',
|
||||
(node.splits ?? []).map((s) => (s === split ? { ...s, fields } : s)),
|
||||
);
|
||||
const kept = new Set(fields.map((f) => splitPortKey(split.port, f.key)));
|
||||
const gone = (e: { from: { node: string; port: string } }) =>
|
||||
e.from.node === node.id && e.from.port.startsWith(`${split.port}.`) && !kept.has(e.from.port);
|
||||
if (graph.edges.some(gone))
|
||||
rec.set(
|
||||
graph,
|
||||
'edges',
|
||||
graph.edges.filter((e) => !gone(e)),
|
||||
);
|
||||
}
|
||||
|
||||
function typedNodes(project: Project): { graph: Graph; node: GraphNode }[] {
|
||||
return project.assets.flatMap((asset) =>
|
||||
isGraphAsset(asset)
|
||||
@@ -474,10 +535,67 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
|
||||
const type = structAssetOf(project, nodeStructType(node));
|
||||
if (type) syncNodePorts(graph, node, typeFields(type), rec);
|
||||
}
|
||||
for (const { graph, node, split } of splitOutputs(project)) {
|
||||
const type = structAssetOf(project, split.structType);
|
||||
if (type) syncSplit(graph, node, split, type, rec);
|
||||
}
|
||||
if (rec.count === before) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nodes whose `item` output is an element of the list fed into their `list` input. */
|
||||
const LIST_ELEMENT_NODES = new Set(['ForEach', FIND, 'ItemAt']);
|
||||
|
||||
/**
|
||||
* What an output is declared as, where the graph says so: a variable of a `Variables` node, the
|
||||
* value of a typed `Pack`, a field of a typed `Expand` or of a split output. Undefined elsewhere.
|
||||
*/
|
||||
function outputDecl(
|
||||
project: Project,
|
||||
ownerId: string,
|
||||
node: GraphNode,
|
||||
port: string,
|
||||
): StructFieldDef | undefined {
|
||||
const split = splitField(node, port);
|
||||
if (split) {
|
||||
const type = structAssetOf(project, split.split.structType);
|
||||
return type && typeFields(type).find((f) => f.key === split.field);
|
||||
}
|
||||
if (node.type === VARIABLES) {
|
||||
const owner = findAsset(project, variablesSourceOf(node.params) || ownerId);
|
||||
const def = owner && isVariableOwner(owner) ? owner.variables.find((v) => v.key === port) : undefined;
|
||||
return def && fieldOfVariable(def);
|
||||
}
|
||||
const typeId = nodeStructType(node);
|
||||
if (!typeId) return undefined;
|
||||
if (node.type === PACK_STRUCT) return { key: port, kind: 'struct', fields: [], structType: typeId };
|
||||
const type = structAssetOf(project, typeId);
|
||||
return type && typeFields(type).find((f) => f.key === port);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Struct asset a value leaving this output is known to be, or `''`. A struct declared with a
|
||||
* type is one; the `item` of `For each`, `Find`, `Match` and `Item at` is one of a typed list fed
|
||||
* into the loop. What flows in through any other way is not followed – the user picks the type.
|
||||
*/
|
||||
export function outputStructType(
|
||||
project: Project,
|
||||
graph: Graph,
|
||||
ownerId: string,
|
||||
node: GraphNode,
|
||||
port: string,
|
||||
): string {
|
||||
const loop = node.type === MATCH ? findsOf(graph, node)[0] : node;
|
||||
if (loop && port === 'item' && LIST_ELEMENT_NODES.has(loop.type)) {
|
||||
const edge = edgesInto(graph, loop.id, 'list')[0];
|
||||
const from = edge && findNode(graph, edge.from.node);
|
||||
const decl = from && outputDecl(project, ownerId, from, edge.from.port);
|
||||
return decl?.kind === 'list' ? (decl.itemType ?? '') : '';
|
||||
}
|
||||
const decl = outputDecl(project, ownerId, node, port);
|
||||
return decl?.kind === 'struct' ? (decl.structType ?? '') : '';
|
||||
}
|
||||
|
||||
/** How many places name the type – what deleting it would cut loose. */
|
||||
export function structTypeUsages(
|
||||
project: Project,
|
||||
@@ -487,7 +605,9 @@ export function structTypeUsages(
|
||||
return {
|
||||
places: typedSites(project).filter((site) => site.structType === typeId).length,
|
||||
lists: lists.filter((id) => id === typeId).length,
|
||||
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
|
||||
nodes:
|
||||
typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length +
|
||||
splitOutputs(project).filter(({ split }) => split.structType === typeId).length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user