import { DisconnectCommand, NameTransitionCommand, SetNodeParamCommand, } from '../../core/commands/graphCommands'; import { findNode, type Graph, type GraphEdge, type GraphNode, isStateNode, machineOf, stateName, TRANSITION_PARAM, transitionNames, } from '../../core/graph'; import { graphLookup } from '../../core/model/graphLookup'; import { textOf } from '../../core/model/Properties'; import { Dropdown, HStack, Icon, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { ARROW_ICON_SVG } from '../panels/markIcons'; import { NAME_WIDTH, removeButton } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; /** The name `State transition` asks for, picked from the machine on its `machine` input – the * options depend on another field, which no field kind can express. */ export function buildTransitionPicker( host: InspectorHost, assetId: string, node: GraphNode, graph: Graph, ): void { const lookup = graphLookup(host.state.project); const machine = machineOf({ node, graph, lookup }); const names = transitionNames(machine); const chosen = textOf(node.params[TRANSITION_PARAM] ?? ''); const known = chosen === '' || names.includes(chosen); const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(new Label('Transition', { style: 'muted' })); row.add(new Spacer()); const choose = new Dropdown(chosen || UNNAMED, { variant: known ? 'default' : 'danger' }); choose.onClick = () => { const items = [UNNAMED, ...names].map((name) => ({ label: name, onSelect: () => host.state.execute( new SetNodeParamCommand(assetId, node.id, TRANSITION_PARAM, name === UNNAMED ? '' : name), ), })); new Menu(items, { searchable: names.length > 8 }).openBelow(choose); }; row.add(choose); host.body.add(row); if (!machine) addNote(host, 'Pick a machine first.'); else if (!known) addNote(host, `"${chosen}" is not a transition of ${machine.name}.`, 'warning'); } /** How the transition nobody named reads in a picker, where an empty row would look like a bug. */ const UNNAMED = '(unnamed)'; /** The transitions at a state, both ways round – all of them at a glance, where the canvas shows * one picked arrow at a time (#289). */ export function buildTransitionSection( host: InspectorHost, assetId: string, node: GraphNode, graph: Graph, ): void { const out = transitionsAt(graph, node.id, true); const into = transitionsAt(graph, node.id, false); group(host, assetId, graph, 'TRANSITIONS OUT', out, true); group(host, assetId, graph, 'TRANSITIONS IN', into, false); const duplicate = firstDuplicate(out.map((e) => e.label ?? '')); if (duplicate !== null) addNote( host, duplicate ? `Two transitions out of here are called "${duplicate}" – neither will be taken.` : 'Two transitions out of here are unnamed – neither will be taken.', 'warning', ); } /** Edges joining this state to a state, on the given side. One back into itself is listed as going * out only – it is one transition, and twice in the panel it would read as two. */ function transitionsAt(graph: Graph, nodeId: string, outgoing: boolean): GraphEdge[] { return graph.edges.filter((e) => { const [mine, other] = outgoing ? [e.from.node, e.to.node] : [e.to.node, e.from.node]; const far = findNode(graph, other); return mine === nodeId && far !== undefined && isStateNode(far) && (outgoing || other !== nodeId); }); } /** The first name that appears twice, or null. An empty name counts: it is the unnamed transition. */ function firstDuplicate(names: readonly string[]): string | null { const seen = new Set(); for (const name of names) { if (seen.has(name)) return name; seen.add(name); } return null; } function group( host: InspectorHost, assetId: string, graph: Graph, title: string, edges: readonly GraphEdge[], outgoing: boolean, ): void { addTitle(host, title); if (edges.length === 0) { addNote(host, outgoing ? 'Nothing leads out of this state yet.' : 'Nothing leads here yet.'); return; } for (const edge of edges) { const far = findNode(graph, outgoing ? edge.to.node : edge.from.node); const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(new Icon(outgoing ? ARROW_ICON_SVG.out : ARROW_ICON_SVG.in)); row.add(new Label(far ? stateName(far) : '?', { style: 'muted' })); row.add(new Spacer()); row.add( new TextField(edge.label ?? '', { width: NAME_WIDTH, onChange: (next) => host.state.execute(new NameTransitionCommand(assetId, edge.id, next)), }), ); row.add(removeButton(() => host.state.execute(new DisconnectCommand(assetId, edge.id)))); host.body.add(row); } }