import { AddPortCommand, AddPortsCommand, nextPortName, RemovePortCommand, RenamePortCommand, RepointPortCommand, SetPortWriteCommand, } from '../../core/commands/portCommands'; import { BEHAVIOR_START, DECLARED_PORT_KINDS, type DeclaredPort, type GraphNode, isTerminalNode, missingFieldPorts, OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_SETTING, OBJECT_TYPE_SETTING, portKindOf, propertyTargetOf, structShapeOf, targetLabel, targetProperties, } from '../../core/graph'; import { findAssetOf } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; import { type PropertyDef, schemaForAsset, VARIABLE_KINDS } from '../../core/properties/PropertySchema'; import { Checkbox, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme, type Widget, } from '../../ui'; import { NAME_WIDTH, removeButton } from './fieldFactory'; import { addNote, type InspectorHost } from './host'; import { KIND_LABELS, kindMark, markLabel } from './variableRows'; /** * 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, assetId: string, node: GraphNode, variables: readonly PropertyDef[], ): void { buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices: variables, owner: 'this behavior', empty: 'This behavior has no variables yet.', }); } /** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */ export function buildPropertyPortSection( host: InspectorHost, assetId: string, node: GraphNode, addSetting: (def: PropertyDef) => void, ): void { const target = propertyTargetOf(node.params); addSetting(OBJECT_TYPE_SETTING); if (target === 'any') { buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true }); return; } if (target !== 'behavior') { const owner = `a ${targetLabel(target)}`; buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: targetProperties(target), owner, }); return; } addSetting(OBJECT_BEHAVIOR_SETTING); const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? ''); const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined; buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: behavior ? schemaForAsset(behavior) : [], owner: behavior ? `"${behavior.name}"` : 'a behavior', empty: id ? 'That behavior is gone.' : 'Pick a behavior first.', }); } interface PickedPorts { noun: 'Variable' | 'Property'; choices: readonly PropertyDef[]; /** Whose ports these are, for the warning on one it does not have. */ owner: string; empty?: string; /** Named by hand instead of picked. */ custom?: boolean; } function buildPickedPortSection( host: InspectorHost, assetId: string, node: GraphNode, { noun, choices, owner, empty, 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 = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) }); const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] => choices.map((def) => ({ label: def.key, disabled: taken.has(def.key) && def.key !== own, onSelect: () => onPick(portOf(def)), })); const typed = (): MenuItem[] => VARIABLE_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => host.state.execute( new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }), ), })); const searchable = noun === 'Property'; const header = new HStack({ gap: Theme.gap, align: 'center' }); 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(custom ? typed() : items, { searchable }).openBelow(add); }; add.setEnabled(custom || choices.some((def) => !taken.has(def.key))); header.add(add); host.body.add(header); if (!custom && choices.length === 0 && empty) addNote(host, empty); else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`); for (const port of ports) { const toggle = writeToggle(host, assetId, node, port); if (custom) { const name = new TextField(port.key, { width: NAME_WIDTH, onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), }); portRow(host, assetId, node, port, name, toggle); continue; } const def = choices.find((d) => d.key === port.key); const known = def !== undefined && portKindOf(def) === port.kind; const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' }); choose.onClick = () => { const items = pick(port.key, (next) => host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)), ); new Menu(items, { searchable }).openBelow(choose); }; portRow(host, assetId, node, port, choose, toggle); if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning'); } } /** * 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 * an *input* of the behavior – which is why the side comes from the node type, not from here. */ export function buildPortSection( host: InspectorHost, assetId: string, node: GraphNode, variables: readonly PropertyDef[] = [], ): void { const onStart = node.type === BEHAVIOR_START; const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field'; const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out'; const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1); if (noun === 'Field') addShapePicker(host, assetId, node, variables, header); const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' }); add.onClick = () => { const items = DECLARED_PORT_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })), })); new Menu(items).openBelow(add); }; header.add(add); host.body.add(header); const ports = node.ports ?? []; if (ports.length === 0) addNote( host, noun === 'Field' ? 'No fields yet. Every field added here becomes a port of the struct.' : onStart ? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.' : 'No outputs yet. Every output added here becomes a result of running this behavior.', ); for (const port of ports) { const name = new TextField(port.key, { width: NAME_WIDTH, onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), }); portRow(host, assetId, node, port, name); } } 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); } /** * Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of * typing them again. Only offered where there is a shape to copy; a field the node already has * stays as it is, so this adds and never rewrites. */ function addShapePicker( host: InspectorHost, assetId: string, node: GraphNode, variables: readonly PropertyDef[], header: HStack, ): void { const shaped = variables.filter((v) => structShapeOf(v) !== undefined); if (shaped.length === 0) return; const pick = new Dropdown('From variable', { variant: 'ghost' }); pick.onClick = () => { const items: MenuItem[] = shaped.map((v) => { const ports = missingFieldPorts(node, structShapeOf(v) ?? []); return { label: v.kind === 'list' ? `${v.key} (item)` : v.key, disabled: ports.length === 0, onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)), }; }); new Menu(items).openBelow(pick); }; header.add(pick); }