The port editor gets 'From variable' next to '+ Field': pick a struct variable of the behavior and its fields land as ports. It only adds - a field the node already declares stays as it is, because it may carry an edge - and the entry is disabled once there is nothing left to add. A list of structs counts too: it declares the shape of every item, which is exactly the bundle a Pack builds for it. A color field arrives as a number port; the graph has no color type. AddPortsCommand is new underneath: adding five ports had to be one undo step, and it checks every key before it appends a single one, so a collision does not leave half the list behind. AddPortCommand is a three-line subclass of it, which leaves its sixteen call sites untouched.
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import {
|
||
BEHAVIOR_PARAM,
|
||
BEHAVIOR_START,
|
||
declaresVariablePorts,
|
||
type DeclaredPort,
|
||
findNode,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isTerminalNode,
|
||
isValidPortName,
|
||
requireNodeDef,
|
||
RUN_BEHAVIOR,
|
||
} from '../graph';
|
||
import type { PropertyValue } from '../model/Properties';
|
||
import type { Project } from '../model/Project';
|
||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||
import { type Command, CommandError } from './Command';
|
||
import { requireBehavior } from './require';
|
||
|
||
interface PortSite {
|
||
graph: Graph;
|
||
nodeId: string;
|
||
/** Whether the port is an input there – the terminal and the call node face opposite ways. */
|
||
asInput: boolean;
|
||
}
|
||
|
||
/**
|
||
* Where a change to `node`'s declaration has to be mirrored. For most nodes that is the node
|
||
* itself. A behavior's terminal is the exception: its ports are also ports of every node calling
|
||
* the behavior – facing the other way, because what a start node hands out the caller passes in.
|
||
*/
|
||
function portSites(project: Project, assetId: string, node: GraphNode): PortSite[] {
|
||
const asset = requireBehavior(project, assetId);
|
||
const asInput = requireNodeDef(node.type).declaresPorts === 'inputs';
|
||
const sites: PortSite[] = [{ graph: asset.graph, nodeId: node.id, asInput }];
|
||
if (!isTerminalNode(node)) return sites;
|
||
for (const other of project.assets) {
|
||
if (other.type !== 'behavior') continue;
|
||
for (const caller of other.graph.nodes) {
|
||
if (caller.type !== RUN_BEHAVIOR || caller.params[BEHAVIOR_PARAM] !== assetId) continue;
|
||
sites.push({ graph: other.graph, nodeId: caller.id, asInput: node.type === BEHAVIOR_START });
|
||
}
|
||
}
|
||
return sites;
|
||
}
|
||
|
||
function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean {
|
||
return (edge) =>
|
||
site.asInput
|
||
? edge.to.node === site.nodeId && edge.to.port === key
|
||
: edge.from.node === site.nodeId && edge.from.port === key;
|
||
}
|
||
|
||
/** The node whose declaration is being edited; a terminal is addressed by id like any other. */
|
||
function requirePortNode(project: Project, assetId: string, nodeId: string): GraphNode {
|
||
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
|
||
if (!node) throw new CommandError(`Node ${nodeId} not found`);
|
||
return node;
|
||
}
|
||
|
||
function nodeAt(site: PortSite): GraphNode | undefined {
|
||
return site.graph.nodes.find((n) => n.id === site.nodeId);
|
||
}
|
||
|
||
function renameParam(site: PortSite, from: string, to: string): void {
|
||
const node = nodeAt(site);
|
||
const value = node?.params[from];
|
||
if (!node || value === undefined) return;
|
||
node.params[to] = value;
|
||
delete node.params[from];
|
||
}
|
||
|
||
// A struct field may be called `exec`, a port may not – so this message has to name that reason.
|
||
const INVALID_NAME =
|
||
'Port names must start with a letter, contain only letters, digits or _, and "exec" is taken by the flow port';
|
||
|
||
/** Several ports as one step, so seeding a node from a struct is one undo rather than one per field. */
|
||
export class AddPortsCommand implements Command {
|
||
readonly label: string;
|
||
protected readonly ports: readonly DeclaredPort[];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
ports: readonly DeclaredPort[],
|
||
) {
|
||
this.ports = ports.map((p) => ({ ...p }));
|
||
this.label = this.ports.length === 1 ? `Add ${this.ports[0]?.key}` : `Add ${this.ports.length} ports`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const keys = new Set((node.ports ?? []).map((p) => p.key));
|
||
for (const port of this.ports) {
|
||
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
|
||
if (keys.has(port.key)) throw new CommandError(`"${port.key}" already exists`);
|
||
keys.add(port.key);
|
||
}
|
||
node.ports = [...(node.ports ?? []), ...this.ports.map((p) => ({ ...p }))];
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const added = new Set(this.ports.map((p) => p.key));
|
||
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
|
||
}
|
||
}
|
||
|
||
export class AddPortCommand extends AddPortsCommand {
|
||
constructor(assetId: string, nodeId: string, port: DeclaredPort) {
|
||
super(assetId, nodeId, [port]);
|
||
}
|
||
}
|
||
|
||
/** Takes the edges hanging on the port with it – in this graph and in every calling one. */
|
||
export class RemovePortCommand implements Command {
|
||
readonly label: string;
|
||
private removed?: Removed<DeclaredPort>;
|
||
private edges: { graph: Graph; entries: Removed<GraphEdge>[] }[] = [];
|
||
private values: { site: PortSite; value: PropertyValue }[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly key: string,
|
||
) {
|
||
this.label = `Remove ${key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const sites = portSites(project, this.assetId, node);
|
||
const ports = [...(node.ports ?? [])];
|
||
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||
if (!removed) throw new CommandError(`Port "${this.key}" not found`);
|
||
this.removed = removed;
|
||
node.ports = ports;
|
||
this.edges = sites.map((site) => ({
|
||
graph: site.graph,
|
||
entries: removeWhere(site.graph.edges, edgesOnPort(site, this.key)),
|
||
}));
|
||
this.values = [];
|
||
for (const site of sites) {
|
||
const target = site.asInput ? nodeAt(site) : undefined;
|
||
const value = target?.params[this.key];
|
||
if (!target || value === undefined) continue;
|
||
this.values.push({ site, value });
|
||
delete target.params[this.key];
|
||
}
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const ports = [...(node.ports ?? [])];
|
||
restoreAt(ports, [this.removed]);
|
||
node.ports = ports;
|
||
for (const { graph, entries } of this.edges) restoreAt(graph.edges, entries);
|
||
for (const { site, value } of this.values) {
|
||
const target = nodeAt(site);
|
||
if (target) target.params[this.key] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */
|
||
export class RenamePortCommand implements Command {
|
||
readonly label: string;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly from: string,
|
||
private readonly to: string,
|
||
) {
|
||
this.label = `Rename ${from}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
this.rename(project, this.from, this.to);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.rename(project, this.to, this.from);
|
||
}
|
||
|
||
private rename(project: Project, from: string, to: string): void {
|
||
if (from === to) return;
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const sites = portSites(project, this.assetId, node);
|
||
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
|
||
const ports = node.ports ?? [];
|
||
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
|
||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||
for (const site of sites) {
|
||
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||
if (site.asInput) edge.to.port = to;
|
||
else edge.from.port = to;
|
||
}
|
||
// An input keeps its typed-in value under the port key, so a rename has to carry it along
|
||
// or the value silently falls back to the default.
|
||
if (site.asInput) renameParam(site, from, to);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
|
||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||
const ports = node.ports ?? [];
|
||
if (from === to || !ports.some((p) => p.key === from)) return;
|
||
const site: PortSite = {
|
||
graph,
|
||
nodeId: node.id,
|
||
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
|
||
};
|
||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
|
||
if (site.asInput) edge.to.port = to;
|
||
else edge.from.port = to;
|
||
}
|
||
if (site.asInput) renameParam(site, from, to);
|
||
}
|
||
|
||
/**
|
||
* Points one declared port at another variable. Key and kind change together, because on a
|
||
* Get/Set variables node the kind is the variable's rather than a choice of its own; the edges
|
||
* hanging on the port come along, so correcting a pick is not a rewiring.
|
||
*/
|
||
export class SetVariablePortCommand implements Command {
|
||
readonly label: string;
|
||
private previous?: DeclaredPort;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly from: string,
|
||
private readonly port: DeclaredPort,
|
||
) {
|
||
this.label = `Point ${from} at ${port.key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||
const ports = node.ports ?? [];
|
||
const current = ports.find((p) => p.key === this.from);
|
||
if (!current) throw new CommandError(`Port "${this.from}" not found`);
|
||
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
|
||
throw new CommandError(`"${this.port.key}" already exists`);
|
||
this.previous = { ...current };
|
||
this.swap(project, node, this.from, this.port);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.previous) return;
|
||
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
|
||
}
|
||
|
||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
|
||
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
|
||
* values move with it – the port key *is* the variable name there.
|
||
*/
|
||
export function renameVariablePorts(graph: Graph, from: string, to: string): void {
|
||
for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
|
||
}
|
||
|
||
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||
export function nextPortName(node: GraphNode, base: string): string {
|
||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
|
||
}
|