Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors

Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.

The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.

Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.

The "Add node..." header button is gone; nodes come from the context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 15:07:07 +02:00
1 parent e9fa592078
commit 854ae2e756
35 files changed
+1044 -101

No files matched your search

+5 -8
View File
@@ -37,7 +37,8 @@ export abstract class AssetTabEditor<
A extends Asset,
C extends TabCanvas & Widget = TabCanvas & Widget,
> extends VStack {
protected readonly picker: Dropdown;
/** Public for the check scripts. */
readonly picker: Dropdown;
protected readonly info: Label;
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
canvas!: C;
@@ -53,8 +54,8 @@ export abstract class AssetTabEditor<
this.info.minWidth = options.infoWidth;
}
/** Builds the header around `canvas`, wires the state events and shows the first asset. */
protected mount(canvas: C, extras: readonly Widget[] = []): void {
/** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
this.canvas = canvas;
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
@@ -67,6 +68,7 @@ export abstract class AssetTabEditor<
header.add(this.info);
this.add(header);
if (strip) this.add(strip);
this.add(canvas, 1);
this.state.on('selection', () => this.followSelection());
@@ -83,11 +85,6 @@ export abstract class AssetTabEditor<
return this.canvas.currentAssetId;
}
/** For the check scripts: the picker's width must not follow the name it happens to show. */
get pickerWidth(): number {
return this.picker.w;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
+17 -14
View File
@@ -18,24 +18,24 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
/** Centre of the first (or, with `last`, the topmost/last-drawn) visible text equal to `text`. */
const locate = (text: string, last = false): { x: number; y: number } | null => {
let found: Text | null = null;
/** Centres of every visible text equal to `text`, in drawing order (the topmost last). */
const locateAll = (text: string): { x: number; y: number }[] => {
const hits: { x: number; y: number }[] = [];
const walk = (node: Container): void => {
if ((found && !last) || !node.visible) return;
if (!node.visible) return;
if (node instanceof Text && node.text === text) {
found = node;
const p = node.getGlobalPosition();
hits.push({ x: p.x + node.width / 2, y: p.y + node.height / 2 });
return;
}
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, hence the disable and the cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!found) return null;
const node = found as Text;
const p = node.getGlobalPosition();
return { x: p.x + node.width / 2, y: p.y + node.height / 2 };
return hits;
};
const locate = (text: string, last = false): { x: number; y: number } | null => {
const hits = locateAll(text);
return (last ? hits.at(-1) : hits[0]) ?? null;
};
/** Fill colour of a visible text, as `#rrggbb` – what tells an override label from a plain one. */
const textColor = (text: string): string | null => {
@@ -160,6 +160,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
locateAll,
inspectorField: fieldOf,
inspectorInfo: () => inspector.debugInfo(),
textColor,
@@ -207,21 +208,23 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
// The canvas, not the whole tab: a centre measured through the header sits above the
// drawing area.
center: () => centreOf(graphEditor.canvas),
pickerWidth: () => graphEditor.pickerWidth,
pickerWidth: () => graphEditor.picker.w,
pickerAt: () => centreOf(graphEditor.picker),
snapping: () => graphEditor.canvas.snapEnabled,
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
},
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
texture: {
current: () => textureEditor.currentAssetId,
pickerWidth: () => textureEditor.pickerWidth,
pickerWidth: () => textureEditor.picker.w,
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.pickerWidth,
pickerWidth: () => containerEditor.picker.w,
center: () => centreOf(containerEditor),
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
+74
View File
@@ -0,0 +1,74 @@
import { Graphics } from 'pixi.js';
import { Button, HStack, Label, Theme, type Widget } from '../../ui';
/** Past this a crumb cuts its name short, so a deep trail of long names still fits the tab. */
const CRUMB_MAX_WIDTH = 160;
/** "Game › Move › Other": every crumb but the last one leads back; empty hides the bar. */
export class Breadcrumb extends HStack {
private readonly bg = new Graphics();
private crumbs: Widget[] = [];
onPick: (index: number) => void = () => undefined;
constructor() {
super({ gap: 2, padding: 3, align: 'center' });
this.addChild(this.bg);
this.visible = false;
}
/** Returns whether the bar changed, so the caller relayouts only then. */
show(names: readonly string[]): boolean {
if (names.length === this.crumbs.length && names.every((n, i) => crumbText(this.crumbs[i]!) === n))
return false;
for (const widget of this.items) widget.destroy({ children: true });
this.crumbs = names.map((name, i) => {
if (i > 0) this.add(new Label('›', { style: 'muted' }));
const last = i === names.length - 1;
return this.add(
capped((width) =>
last
? new Label(name, { width, padding: Theme.padding })
: new Button(name, { variant: 'ghost', width, onClick: () => this.onPick(i) }),
),
);
});
this.visible = names.length > 0;
// The bar keeps its size across a rebuild, and `resize` lays out only on a new size.
this.relayout();
return true;
}
/** For the check scripts: what each crumb says, where it sits, and whether it leads anywhere. */
crumbsDrawn(): { name: string; x: number; y: number; left: number; right: number; clickable: boolean }[] {
if (!this.visible) return [];
return this.crumbs.map((widget) => {
const at = widget.getGlobalPosition();
return {
name: crumbText(widget),
x: at.x + widget.w / 2,
y: at.y + widget.h / 2,
left: at.x,
right: at.x + widget.w,
clickable: widget instanceof Button,
};
});
}
override redraw(): void {
super.redraw();
this.bg.clear();
this.bg.rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader);
this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border);
}
}
function crumbText(widget: Widget): string {
return widget instanceof Button || widget instanceof Label ? widget.text : '';
}
function capped(build: (width?: number) => Widget): Widget {
const natural = build();
if (natural.measure().w <= CRUMB_MAX_WIDTH) return natural;
natural.destroy({ children: true });
return build(CRUMB_MAX_WIDTH);
}
+3 -11
View File
@@ -112,6 +112,8 @@ export class GraphCanvas extends Widget {
/** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null;
timeProvider: () => number = () => 0;
/** A double-clicked call node; the tab opens the behavior and records the step. */
onOpenBehavior: (assetId: string) => void = (assetId) => this.state.select({ kind: 'asset', assetId });
constructor(private readonly state: EditorState) {
super();
@@ -173,15 +175,6 @@ export class GraphCanvas extends Widget {
return this.snapping;
}
/** Drops the node in the middle of the view, since a button press names no position. */
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
const centre = this.screenToGraph(this.w / 2, this.h / 2);
new NodePalette(this.entries(), (entry) =>
this.addNode(entry, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }),
).openBelow(anchor);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
@@ -367,13 +360,12 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view); // raise
}
/** Selecting the asset is what opens it: the graph tab follows the selection. */
private openCalledBehavior(node: GraphNode): boolean {
if (node.type !== RUN_BEHAVIOR) return false;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
if (asset?.type !== 'behavior') return false;
this.state.select({ kind: 'asset', assetId: id });
this.onOpenBehavior(id);
return true;
}
+39 -6
View File
@@ -1,25 +1,59 @@
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { Button, Checkbox, Label } from '../../ui';
import { findAsset, findAssetOf } from '../../core/model/Project';
import { Checkbox, Label } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { Breadcrumb } from './Breadcrumb';
import { GraphCanvas } from './GraphCanvas';
import { backTo, descend, follow, type Trail } from './trail';
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
const INFO_WIDTH = 150;
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
private readonly addButton: Button;
private readonly snapBox = new Checkbox();
readonly breadcrumb = new Breadcrumb();
private trail: Trail = [];
constructor(state: EditorState) {
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
this.snapBox.onChange = (on) => this.canvas.setSnapping(on);
this.mount(new GraphCanvas(state), [this.addButton, new Label('Snap', { style: 'muted' }), this.snapBox]);
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
this.breadcrumb.onPick = (index) => this.open(this.trail[index]!, backTo(this.trail, index));
this.mount(
new GraphCanvas(state),
[new Label('Snap', { style: 'muted' }), this.snapBox],
this.breadcrumb,
);
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
// Edits made while playing would be thrown away with the run.
state.on('mode', () => this.refresh());
}
/**
* The bar goes up before the graph switches: switching fits the view to the canvas, and a bar
* appearing afterwards would cut the bottom of the fitted graph off.
*/
private open(assetId: string, trail: Trail): void {
this.trail = trail;
this.showTrail();
this.state.select({ kind: 'asset', assetId });
this.refresh();
}
protected override refresh(): void {
super.refresh();
const project = this.state.project;
this.trail = follow(this.trail, this.currentAssetId, (id) =>
Boolean(findAssetOf(project, id, 'behavior')),
);
this.showTrail();
}
private showTrail(): void {
const names = this.trail.map((id) => findAsset(this.state.project, id)?.name ?? '');
if (this.breadcrumb.show(names)) this.relayout();
}
protected override selectedId(): string {
return this.state.selectedBehaviorId ?? '';
}
@@ -36,7 +70,6 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
protected override syncExtras(asset: BehaviorAsset | null): void {
const editable = Boolean(asset) && this.state.mode === 'edit';
this.addButton.setEnabled(editable);
this.snapBox.setEnabled(editable);
}
}
+23 -9
View File
@@ -1,9 +1,11 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import {
asNumber,
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputDef,
inlineInputs,
inlineValue,
type NodeCategory,
@@ -15,10 +17,11 @@ import {
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import type { Project } from '../../core/model/Project';
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
import { isList, type PropertyValue } from '../../core/model/Properties';
import { assetRefLabel } from '../../core/properties/assetRef';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui';
import { textureRefLabel } from '../../core/properties/textureRef';
import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
@@ -93,6 +96,7 @@ export class NodeView extends Widget {
targetPort: { port: string; isOutput: boolean } | null = null;
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
private inlineTexts = new Map<string, string>();
/** Which sides carry a link – a collapsed node marks those with one dot each. */
private linked = { inputs: false, outputs: false };
@@ -148,6 +152,12 @@ export class NodeView extends Widget {
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
this.inlineTexts = new Map();
for (const port of inlineInputs(this.def)) {
const value = inlineValue(node, port);
if (!this.connected.has(port.key) && value !== null && value !== '')
this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project));
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
@@ -266,11 +276,8 @@ export class NodeView extends Widget {
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
if (input) {
let label = input.label;
if (input.type !== 'exec' && input.type !== 'object' && !this.connected.has(input.key)) {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
const inline = this.inlineTexts.get(input.key);
const label = inline === undefined ? input.label : `${input.label} ${inline}`;
add(
label,
input.type === 'exec' ? 'body' : 'muted',
@@ -369,8 +376,6 @@ function formatParam(
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(textOf(value));
if (param.kind === 'asset') return assetRefLabel(project, textOf(value));
if (
param.kind === 'enum' &&
param.key === 'variable' &&
@@ -378,6 +383,15 @@ function formatParam(
!asset.variables.some((v) => v.key === value)
)
return `${asString(value)}?`;
return formatField(param, value, project);
}
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
if (def.kind === 'key') return keyLabel(asString(value));
if (def.kind === 'asset') return assetRefLabel(project, asString(value));
if (def.kind === 'texture') return textureRefLabel(project, asString(value));
if (def.kind === 'color') return toHex(asNumber(value));
return formatValue(value);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The behaviors entered by double-clicking call nodes, root first. Either empty or at least two
* long, and always ending at the behavior the graph shows – anything else opened it otherwise.
*/
export type Trail = readonly string[];
/** Opening `to` from a call node in `from`. A behavior already on the trail is a step back to it. */
export function descend(trail: Trail, from: string, to: string): Trail {
const base = trail.at(-1) === from ? trail : [from];
const known = base.indexOf(to);
return known >= 0 ? backTo(base, known) : [...base, to];
}
/** Clicking crumb `index`; back at the root there is nothing left to show. */
export function backTo(trail: Trail, index: number): Trail {
const next = trail.slice(0, index + 1);
return next.length > 1 ? next : [];
}
/** What survives the graph showing `shown`: only a trail that still leads there, every step still alive. */
export function follow(trail: Trail, shown: string, exists: (id: string) => boolean): Trail {
return trail.at(-1) === shown && trail.every(exists) ? trail : [];
}
+19 -1
View File
@@ -251,7 +251,25 @@ export function createField(
}
case 'text': {
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
const setValue = (v: PropertyValue) => field.setValue(textOf(v));
const suggestions = def.suggestions;
if (!suggestions) return { widget: field, grow: true, setValue };
const pick = new Dropdown('', { variant: 'ghost', width: MARK_WIDTH });
pick.onClick = () => {
const current = field.value.trim();
const items = suggestions.map((s) => ({
label: s.value === current ? `● ${s.label}` : s.label,
onSelect: () => {
setValue(s.value);
handlers.onChange(s.value);
},
}));
new Menu(items, { searchable: true }).openBelow(pick);
};
const combo = new HStack({ gap: Theme.gap, align: 'center' });
combo.add(field, 1);
combo.add(pick);
return { widget: combo, grow: true, setValue };
}
case 'color': {
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
+3 -1
View File
@@ -1,5 +1,6 @@
import {
callProblem,
declaresPropertyPorts,
declaresVariablePorts,
edgesInto,
type GraphNode,
@@ -15,7 +16,7 @@ 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, buildVariablePortSection } from './portSection';
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { currentNode, nodeParamValue } from './valueSpecs';
@@ -52,6 +53,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
if (problem) addNote(host, problem, 'warning');
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
else if (declaresPropertyPorts(node.type)) buildPropertyPortSection(host, assetId, node);
else if (def.declaresPorts) buildPortSection(host, assetId, node, asset.variables);
if (def.params.length > 0) addTitle(host, 'SETTINGS');
+80 -17
View File
@@ -4,7 +4,7 @@ import {
nextPortName,
RemovePortCommand,
RenamePortCommand,
SetVariablePortCommand,
RepointPortCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
@@ -13,10 +13,16 @@ import {
type GraphNode,
isTerminalNode,
missingFieldPorts,
objectPropertyDef,
portKindOf,
propertyPortOf,
structShapeOf,
variablePortKind,
} from '../../core/graph';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import {
OBJECT_PROPERTY_CHOICES,
type PropertyDef,
VARIABLE_KINDS,
} from '../../core/properties/PropertySchema';
import {
Dropdown,
HStack,
@@ -42,44 +48,101 @@ export function buildVariablePortSection(
node: GraphNode,
variables: readonly PropertyDef[],
): void {
const choices = variables.map((def) => ({ label: def.key, def }));
buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices });
}
/**
* The ports of a Get/Set properties node: a built-in property is picked like a variable, and a name
* the list does not know (a variable of another behavior's object) is typed, with a kind of its own.
*/
export function buildPropertyPortSection(host: InspectorHost, assetId: string, node: GraphNode): void {
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: OBJECT_PROPERTY_CHOICES,
custom: true,
});
}
interface PickedPorts {
noun: 'Variable' | 'Property';
choices: readonly { label: string; def: PropertyDef }[];
custom?: boolean;
}
function buildPickedPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
{ noun, choices, custom = false }: PickedPorts,
): void {
const plural = noun === 'Property' ? 'properties' : 'variables';
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) });
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
variables.map((v) => ({
label: v.key,
disabled: taken.has(v.key) && v.key !== own,
onSelect: () => onPick(portOf(v)),
choices.map(({ label, def }) => ({
label,
disabled: taken.has(def.key) && def.key !== own,
onSelect: () => onPick(portOf(def)),
}));
const typed = (): MenuItem[] =>
VARIABLE_KINDS.map((kind) => ({
label: `Custom ${KIND_LABELS[kind].toLowerCase()}`,
onSelect: () =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
}));
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Dropdown('+ Variable', { variant: 'ghost' });
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(items).openBelow(add);
new Menu(custom ? [...items, ...typed()] : items, { searchable: custom }).openBelow(add);
};
add.setEnabled(variables.length > taken.size);
add.setEnabled(custom || choices.some(({ def }) => !taken.has(def.key)));
header.add(add);
host.body.add(header);
if (variables.length === 0) addNote(host, 'This behavior has no variables yet.');
else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.');
if (choices.length === 0) addNote(host, `This behavior has no ${plural} yet.`);
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const known = choices.some(({ def }) => def.key === port.key);
if (!known && custom) {
portRow(host, assetId, node, port, customName(host, assetId, node, port));
continue;
}
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)),
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
);
new Menu(items).openBelow(choose);
new Menu(items, { searchable: custom }).openBelow(choose);
};
portRow(host, assetId, node, port, choose);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
/** Typing a built-in property's name turns the port into that property, kind included. */
function customName(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
onChange: (text) => {
const next = text.trim();
const builtIn = objectPropertyDef(next);
host.state.execute(
builtIn
? new RepointPortCommand(assetId, node.id, port.key, propertyPortOf(builtIn))
: new RenamePortCommand(assetId, node.id, port.key, next),
);
},
});
}
/**
* 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