import { callProblem, declaresPropertyPorts, declaresSoundFxPorts, declaresVariablePorts, edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode, inlineInputDef, inlineInputs, isStateNode, MACHINE_PORT, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM, portSignature, resolveDef, soundFxParamsMismatch, soundFxSourceOf, STATE_TRANSITION, stateName, STRUCT_TYPE_PARAM, STRUCT_TYPE_SETTING, TRANSITION_PARAM, variablesSourceOf, } from '../../core/graph'; import { graphLookup } from '../../core/model/graphLookup'; import { findAssetOf, findGraphAsset, type GraphHolder, type Project } from '../../core/model/Project'; import { type PropertyValue, textOf } from '../../core/model/Properties'; import { isListNode, isTypedNode, listNodeItemType, nodeStructType, structAssetOf, } from '../../core/model/structTypes'; import { SetListNodeTypeCommand, SetStructTypeCommand } from '../../core/commands/structTypeCommands'; 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, buildPropertyPortSection, buildSoundFxPortSection, buildVariablePortSection, } from './portSection'; import { variableChoices } from '../graph/portChoices'; import { listSource, previewingHandlers } from './previewHandlers'; import { buildTransitionPicker, buildTransitionSection } from './transitionSection'; import { currentNode, nodeParamValue } from './valueSpecs'; /** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */ export function nodeSignature(project: Project, assetId: string, nodeId: string): string { const asset = findGraphAsset(project, assetId); if (!asset) return 'missing'; const node = asset.graph.nodes.find((n) => n.id === nodeId); if (!node) return 'missing'; const lookup = graphLookup(project); const def = resolveDef(node, asset.graph, lookup); const connected = def ? inlineInputs(def) .filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0) .map((p) => p.key) : []; // The machine a port carries is drawn as the picker's text, its name included. const machineOf = (id: string) => findAssetOf(project, id, 'statemachine')?.name ?? ''; const declared = (node.ports ?? []) .map( (p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}${p.machine ? `:${p.machine}:${machineOf(p.machine)}` : ''}${ p.default !== undefined ? `=${p.default}` : '' }`, ) .join(','); // They decide which rows the inspector builds, and two behaviors may share a name in the title. const target = `${textOf(node.params[OBJECT_TYPE_PARAM] ?? '')}:${textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')}`; // The transition rows and the picked name are drawn, not filled in by `refreshNodeSection`, so // every part of them a command can change has to force a rebuild. const transitions = isStateNode(node) ? edgesOfNode(asset.graph, node.id) .map((e) => `${e.id}:${e.label ?? ''}:${farStateName(asset.graph, e, node.id)}`) .join(',') : `${textOf(node.params[TRANSITION_PARAM] ?? '')}:${textOf(node.params[MACHINE_PORT] ?? '')}`; // The source names which declarations the port menu offers, so switching it has to rebuild. const typeId = nodeStructType(node) || listNodeItemType(node); const typeName = structAssetOf(project, typeId)?.name ?? ''; const source = `${variablesSourceOf(node.params)}:${soundFxSourceOf(node.params)}:${typeId}:${typeName}`; const choices = variableChoices(project, assetId, node) .map((v) => v.key) .join(','); // A setting that does nothing has no row, so picking an easing that needs it has to rebuild. const idle = def?.idleParams?.(node.params, (key) => connected.includes(key)).join(',') ?? ''; return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join( ',', )}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}|${idle}`; } /** The name of the state at the other end of an edge – what a transition row is labelled with. */ function farStateName(graph: Graph, edge: GraphEdge, nodeId: string): string { const far = findNode(graph, edge.from.node === nodeId ? edge.to.node : edge.from.node); return far && isStateNode(far) ? stateName(far) : ''; } /** A state machine declares no variables; its one node type has none to name. */ const variablesOf = (asset: GraphHolder): PropertyDef[] => (asset.type === 'behavior' ? asset.variables : []); /** Description, settings (params) and the inline values of unconnected data inputs. */ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void { const asset = findGraphAsset(host.state.project, assetId); if (!asset) return; const node = asset.graph.nodes.find((n) => n.id === nodeId); const def = node && resolveDef(node, asset.graph, graphLookup(host.state.project)); if (!node || !def) return; addTitle(host, `${def.category.toUpperCase()} NODE`); host.body.add(new Label(def.label, { style: 'bodyBold' })); addNote(host, def.description); const problem = callProblem(node, graphLookup(host.state.project), assetId) ?? soundFxParamsMismatch(node, asset.graph); if (problem) addNote(host, problem, 'warning'); const addSetting = (param: PropertyDef, handlers = paramHandlers(host, assetId, nodeId, param)) => { const row = createFieldRow(host, param, node.params[param.key] ?? param.default, handlers); host.fields.set(`param:${param.key}`, row); host.body.add(row.row); }; // Both draw their settings inside the port section: a setting decides what the menu offers. const ownSettings = declaresPropertyPorts(node.type) || declaresVariablePorts(node.type) || declaresSoundFxPorts(node.type); // The type of a Pack/Expand decides its fields (#253), so it stands above them – and picking one // rewrites the ports, which a plain param edit would not. const typed = isTypedNode(node); if (typed) { const pick = (value: PropertyValue) => { const target = { kind: 'node', assetId, nodeId } as const; host.state.execute(new SetStructTypeCommand(target, textOf(value))); }; addSetting(STRUCT_TYPE_SETTING, { onInput: () => {}, onChange: pick }); } // A List node's type reshapes its items (#271), which is no plain param edit either. const pickItemType = (value: PropertyValue) => host.state.execute(new SetListNodeTypeCommand(assetId, nodeId, textOf(value))); if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, addSetting); else if (declaresSoundFxPorts(node.type)) buildSoundFxPortSection(host, assetId, node, addSetting); else if (ownSettings) buildPropertyPortSection(host, assetId, node, addSetting); else if (def.declaresPorts) { const typeName = structAssetOf(host.state.project, nodeStructType(node))?.name ?? null; buildPortSection(host, assetId, node, variablesOf(asset), typeName); } const fed = (key: string) => edgesInto(asset.graph, node.id, key).length > 0; const idle = new Set(def.idleParams?.(node.params, fed) ?? []); const settings = def.params.filter( (param) => !(typed && param.key === STRUCT_TYPE_PARAM) && !idle.has(param.key), ); if (!ownSettings && settings.length > 0) { addTitle(host, 'SETTINGS'); for (const param of settings) { if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION) buildTransitionPicker(host, assetId, node, asset.graph); else if (param.key === STRUCT_TYPE_PARAM && isListNode(node)) addSetting(param, { onInput: () => {}, onChange: pickItemType }); else addSetting(param); } } if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph); const free = inlineInputs(def).filter((p) => !fed(p.key)); if (free.length > 0) addTitle(host, 'INPUTS (unconnected)'); for (const port of free) { const inline = inlineInputDef(port); const row = createFieldRow( host, inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, inline), ); host.fields.set(`input:${port.key}`, row); row.row.setEnabled(!idle.has(port.key)); host.body.add(row.row); } } /** Pushes the node's current params into the rows. False when the node is gone (rebuild). */ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean { const found = currentNode(host.state.project, assetId, nodeId); const def = found && resolveDef(found.node, found.graph, graphLookup(host.state.project)); if (!found || !def) return false; for (const [key, row] of host.fields) { const [scope, name] = key.split(':') as ['param' | 'input', string]; const port = def.inputs.find((p) => p.key === name); const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : port && inlineInputDef(port).default; row.setValue(found.node.params[name] ?? fallback ?? ''); } return true; } /** Previews straight into the node's params; the command records the value from before the preview. */ function paramHandlers( host: InspectorHost, assetId: string, nodeId: string, def: PropertyDef, ): FieldHandlers { const key = def.key; return { ...previewingHandlers(host, nodeParamValue(host.state, assetId, nodeId, key)), ...listSource(host, def, () => ({ kind: 'node', assetId, nodeId, key })), }; }