Replace the five variable nodes with one Variables node

A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 16:56:24 +02:00
1 parent 854ae2e756
commit 7c63c884ef
40 files changed
+895 -590

No files matched your search

+2
View File
@@ -196,6 +196,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
/** Each input row as drawn: its name, and the inline value beside it while unconnected. */
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
+5 -1
View File
@@ -213,7 +213,7 @@ export class GraphCanvas extends Widget {
this.views.set(node.id, view);
this.nodeLayer.addChild(view);
}
view.sync(node, asset.graph, asset, this.state.project, def, callProblem(node, lookup, asset.id));
view.sync(node, asset.graph, this.state.project, def, callProblem(node, lookup, asset.id));
}
for (const [id, view] of this.views) {
if (!seen.has(id)) {
@@ -790,6 +790,10 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.settingLines ?? null;
}
nodeInputs(nodeId: string): string[] | null {
return this.views.get(nodeId)?.inputLines ?? null;
}
/** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
+15 -23
View File
@@ -15,7 +15,6 @@ import {
type PortType,
type Value,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
@@ -114,7 +113,7 @@ export class NodeView extends Widget {
this.def = def;
this.cursor = 'grab';
this.addChild(this.box, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null, null, def);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
@@ -127,7 +126,6 @@ export class NodeView extends Widget {
sync(
node: GraphNode,
graph: Graph,
asset: BehaviorAsset | null,
project: Project | null,
def: NodeDef,
problem: string | null = null,
@@ -141,10 +139,10 @@ export class NodeView extends Widget {
.filter((p) => !hidden.has(p.key))
.map((p) => ({
label: p.label,
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
value: formatParam(p, node.params[p.key] ?? p.default, project),
}))
// A title made from a setting states it ("Behavior: Double" under "Double", "Variable: speed"
// under "Get speed"). A dangling reference keeps its row: "speed?" is not in the title.
// A title made from a setting states it ("Behavior: Double" under "Double"). A dangling
// reference keeps its row: "Double?" is not in the title.
.filter((p) => !derived || (p.value !== title && !title.endsWith(` ${p.value}`)))
.map((p) => `${p.label}: ${p.value}`);
this.connected = new Set(
@@ -200,6 +198,15 @@ export class NodeView extends Widget {
return [...this.paramLines];
}
get inputLines(): string[] {
return this.def.inputs.map((input) => this.inputText(input));
}
private inputText(input: PortDef): string {
const inline = this.inlineTexts.get(input.key);
return inline === undefined ? input.label : `${input.label} ${inline}`;
}
/** Everything above the first port row. The problem line sits in here, so ports move down with it. */
private headerHeight(): number {
const lines = this.paramLines.length + (this.problem ? 1 : 0);
@@ -276,10 +283,8 @@ export class NodeView extends Widget {
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
if (input) {
const inline = this.inlineTexts.get(input.key);
const label = inline === undefined ? input.label : `${input.label} ${inline}`;
add(
label,
this.inputText(input),
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
rowTop,
@@ -368,21 +373,8 @@ export class NodeView extends Widget {
}
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(
param: PropertyDef,
value: PropertyValue | undefined,
asset: BehaviorAsset | null,
project: Project | null,
): string {
function formatParam(param: PropertyDef, value: PropertyValue | undefined, project: Project | null): string {
if (value === undefined || value === '') return '—';
if (
param.kind === 'enum' &&
param.key === 'variable' &&
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${asString(value)}?`;
return formatField(param, value, project);
}
+4 -15
View File
@@ -9,7 +9,6 @@ import {
portSignature,
resolveDef,
} from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
@@ -33,7 +32,7 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
.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}`).join(',');
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).join(',');
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
.map((v) => v.key)
.join(',')}`;
@@ -58,12 +57,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
const effective = resolveParamDef(param, asset);
const row = createFieldRow(
host,
effective,
node.params[param.key] ?? effective.default,
paramHandlers(host, assetId, nodeId, effective),
param,
node.params[param.key] ?? param.default,
paramHandlers(host, assetId, nodeId, param),
);
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
@@ -101,15 +99,6 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
return true;
}
/**
* Variable params are the one setting a node cannot declare on its own: their options are the
* variables of the behavior it sits in. Everything else is used exactly as declared.
*/
function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
if (param.kind !== 'enum' || param.key !== 'variable' || asset.type !== 'behavior') return param;
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
/** Previews straight into the node's params; the command records the value from before the preview. */
function paramHandlers(
host: InspectorHost,
+20 -3
View File
@@ -5,6 +5,7 @@ import {
RemovePortCommand,
RenamePortCommand,
RepointPortCommand,
SetPortWriteCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
@@ -24,6 +25,7 @@ import {
VARIABLE_KINDS,
} from '../../core/properties/PropertySchema';
import {
Checkbox,
Dropdown,
HStack,
Label,
@@ -39,8 +41,8 @@ import { addNote, type InspectorHost } from './host';
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
/**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
* from that list rather than named, and its kind follows the variable's declaration.
* The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that
* list rather than named, and its kind follows the variable's declaration. Set makes it written too.
*/
export function buildVariablePortSection(
host: InspectorHost,
@@ -122,7 +124,14 @@ function buildPickedPortSection(
);
new Menu(items, { searchable: custom }).openBelow(choose);
};
portRow(host, assetId, node, port, choose);
portRow(
host,
assetId,
node,
port,
choose,
noun === 'Variable' ? writeToggle(host, assetId, node, port) : [],
);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
@@ -193,17 +202,25 @@ export function buildPortSection(
}
}
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(kindMark(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);