import { AddPortCommand, AddPortsCommand, nextPortName, RemovePortCommand, RenamePortCommand, RepointPortCommand, SetPortWriteCommand, } from '../../core/commands/portCommands'; import { BEHAVIOR_START, type DeclaredPort, type GraphNode, isPortOf, isTerminalNode, missingFieldPorts, OBJECT_BEHAVIOR_PARAM, OBJECT_BEHAVIOR_SETTING, OBJECT_TYPE_SETTING, portKindOf, VARIABLES_SETTING, variablesSourceOf, propertyTargetOf, structShapeOf, targetLabel, targetProperties, } from '../../core/graph'; import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds'; import { allShaderParamRows, type PropertyDef, schemaForAsset } 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 { kindMenu, markLabel } from './variableRows'; /** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */ export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] { const source = variablesSourceOf(node.params); if (source) return findAssetOf(project, source, 'variables')?.variables ?? []; const asset = findGraphAsset(project, assetId); // A state machine declares no variables; its one node type has none to name. return asset?.type === 'behavior' ? asset.variables : []; } /** * The ports of a Variables node: each one *is* a declared variable, so it is picked from a list * rather than named, and its kind follows the declaration. Set makes it written too. */ export function buildVariablePortSection( host: InspectorHost, assetId: string, node: GraphNode, addSetting: (def: PropertyDef) => void, ): void { addSetting(VARIABLES_SETTING); const source = variablesSourceOf(node.params); const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined; buildPickedPortSection(host, assetId, node, { noun: 'Variable', choices: variableChoices(host.state.project, assetId, node), owner: shared ? `"${shared.name}"` : 'this behavior', empty: source ? shared ? `"${shared.name}" declares no variables yet.` : 'That Variables asset is gone.' : '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)}`; // A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot // know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and a // sprite property may share a name, and only the shader's entry earns the note. const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : []; const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')])); buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [...targetProperties(target), ...shaderRows.map((row) => row.def)], owner, note: (def) => from.get(def), }); 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(host.state.project, 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; /** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */ note?: (def: PropertyDef) => string | undefined; } function buildPickedPortSection( host: InspectorHost, assetId: string, node: GraphNode, { noun, choices, owner, empty, custom = false, note }: 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 choiceLabel = (def: PropertyDef): string => { const where = note?.(def); return where ? `${def.key} (${where})` : def.key; }; const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] => choices.map((def) => ({ label: choiceLabel(def), disabled: taken.has(def.key) && def.key !== own, onSelect: () => onPick(portOf(def)), })); const typed = (): MenuItem[] => kindMenu(VARIABLE_KINDS, (kind) => 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) { portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle); continue; } const def = choices.find((d) => d.key === port.key); const known = def !== undefined && isPortOf(port, def); 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, def && known ? { ...port, kind: portKindOf(def) } : 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 = kindMenu(DECLARED_PORT_KINDS, (kind) => 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) portRow(host, assetId, node, port, portNameField(host, assetId, node, port)); } function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField { return new TextField(port.key, { width: NAME_WIDTH, onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), }); } 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(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); }