A transition carries a name, so a state needs no visible port (#52)
game-maker / suite (push) Successful in 10m22s
game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.
Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.
Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.
`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.
The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.
One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a041322a0e
commit
68c92daaa6
27 files changed
+1186
-38
No files matched your search
@@ -242,6 +242,39 @@ export class FoldEdgeCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */
|
||||
export class NameTransitionCommand implements Command {
|
||||
readonly label = 'Name transition';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly edgeId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const edge = this.edge(project);
|
||||
this.previous = edge.label ?? '';
|
||||
this.apply(edge, this.name.trim());
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.apply(this.edge(project), this.previous);
|
||||
}
|
||||
|
||||
private edge(project: Project): GraphEdge {
|
||||
const edge = requireGraph(project, this.assetId).edges.find((e) => e.id === this.edgeId);
|
||||
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||
return edge;
|
||||
}
|
||||
|
||||
private apply(edge: GraphEdge, name: string): void {
|
||||
if (name) edge.label = name;
|
||||
else delete edge.label;
|
||||
}
|
||||
}
|
||||
|
||||
/** One undo step for the whole selection; every node is found before any of them changes. */
|
||||
export class CollapseNodesCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface GraphEdge {
|
||||
to: PortRef;
|
||||
/** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */
|
||||
folded?: boolean;
|
||||
/** What a transition between two states is called – the name `State transition` asks for. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Graph {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class GraphInterpreter {
|
||||
const def = this.def(node);
|
||||
if (!def?.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params, def, entry);
|
||||
const result = def.run(ctx, inputs, node.params, def, entry, this.lookup);
|
||||
const returned = result.call ? this.call(result.call, ctx, depth) : {};
|
||||
const produced = { ...result.outputs, ...returned };
|
||||
if (!def.read) this.frame.outputCache.set(node.id, produced);
|
||||
@@ -197,7 +197,8 @@ export class GraphInterpreter {
|
||||
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||
this.frame.dataCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def, '').outputs ?? {};
|
||||
const outputs =
|
||||
def.run(ctx, this.collectInputs(source, ctx), source.params, def, '', this.lookup).outputs ?? {};
|
||||
this.frame.dataCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
this.record(nodeId, outputs);
|
||||
|
||||
@@ -91,6 +91,12 @@ export interface NodeDef {
|
||||
declaresPorts?: 'inputs' | 'outputs' | 'both';
|
||||
/** How a declared port becomes a port; without it, named after its key and typed by its kind. */
|
||||
declaredPort?(port: DeclaredPort, node: GraphNode): PortDef;
|
||||
/**
|
||||
* Edges between two such nodes are transitions: drawn as a straight arrow instead of the usual
|
||||
* curve, and hung on ports that are never shown. A transition graph is read as directed steps
|
||||
* between whole states, not as signal flowing left to right into a particular socket.
|
||||
*/
|
||||
transitionEdges?: true;
|
||||
/**
|
||||
* Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence
|
||||
* offers, which values the behavior a call node names takes. `resolveDef` applies it.
|
||||
@@ -105,7 +111,8 @@ export interface NodeDef {
|
||||
/**
|
||||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||||
* hand, the only way a node with derived ports knows how many it has. `entry` is the exec input
|
||||
* the pulse arrived through – what a node with one exec input per meaning reads.
|
||||
* the pulse arrived through – what a node with one exec input per meaning reads. `lookup` is the
|
||||
* project around the graph, for a node whose step is written in another asset.
|
||||
*/
|
||||
run?(
|
||||
ctx: NodeContext,
|
||||
@@ -113,6 +120,7 @@ export interface NodeDef {
|
||||
params: PropertyBag,
|
||||
def: NodeDef,
|
||||
entry: string,
|
||||
lookup: GraphLookup,
|
||||
): RunResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,20 @@ export interface MachineState {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An allowed move between two states, both named by state node id, under the name `State
|
||||
* transition` asks for. The name is empty until someone gives the transition one.
|
||||
*/
|
||||
export interface MachineTransition {
|
||||
from: string;
|
||||
to: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface StateMachine {
|
||||
name: string;
|
||||
states: MachineState[];
|
||||
transitions: MachineTransition[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import { flatDef } from '../../../properties/PropertySchema';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { machinePort, STATE_TRANSITION, TRANSITION_PARAM, transitionTo } from '../../stateMachine';
|
||||
import { asString } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/** What the transitions drawn in the machine are for at runtime: one step along a named arrow. */
|
||||
export const StateTransition: NodeDef = {
|
||||
type: STATE_TRANSITION,
|
||||
label: 'State transition',
|
||||
category: 'action',
|
||||
description:
|
||||
"Takes the transition of this name out of the machine's current state, then continues. " +
|
||||
'Nothing happens when that state has no transition of the name.',
|
||||
inputs: [exec(), machinePort()],
|
||||
outputs: [exec()],
|
||||
params: [flatDef(TRANSITION_PARAM, 'text', { label: 'Transition', value: '' })],
|
||||
run: (ctx, inputs, params, _def, _entry, lookup) => {
|
||||
const id = asString(inputs.machine);
|
||||
const current = ctx.states[id] ?? '';
|
||||
const machine = current ? lookup.machine(id) : null;
|
||||
const name = textOf(params[TRANSITION_PARAM] ?? '');
|
||||
const target = machine ? transitionTo(machine, current, name) : '';
|
||||
if (target) ctx.states[id] = target;
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -89,6 +89,7 @@ import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { SetState } from './flow/SetState';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { StateTransition } from './flow/StateTransition';
|
||||
import { PARTICLE_NODES } from '../../particles/nodes';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
@@ -120,6 +121,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
RunBehavior,
|
||||
StateBranch,
|
||||
SetState,
|
||||
StateTransition,
|
||||
BehaviorEnd,
|
||||
Variables,
|
||||
Self,
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import { edgesInto, type Graph, type GraphNode } from './Graph';
|
||||
import type { MachineState, StateMachine } from './lookup';
|
||||
import type { MachineState, MachineTransition, StateMachine } from './lookup';
|
||||
import type { NodeDef, PortDef, PortSource } from './NodeRegistry';
|
||||
|
||||
export const STATE = 'State';
|
||||
export const STATE_MACHINE = 'StateMachine';
|
||||
export const STATE_TRANSITION = 'StateTransition';
|
||||
export const STATE_NAME_PARAM = 'name';
|
||||
/** The input naming the machine on `State branch` and `Set state`. */
|
||||
export const MACHINE_PORT = 'machine';
|
||||
/** The setting naming the transition on `State transition`. */
|
||||
export const TRANSITION_PARAM = 'transition';
|
||||
/** The two ports a transition hangs on. A port is the only anchor an edge has – without one it
|
||||
* cannot be drawn, selected or undone – so a state carries these, and the editor never draws
|
||||
* them: a transition joins whole states, not sockets. */
|
||||
export const STATE_IN = 'in';
|
||||
export const STATE_OUT = 'out';
|
||||
|
||||
/**
|
||||
* One state of a state machine: a node with no ports whose name is its title. Its graph is the
|
||||
* machine asset's, never a behavior's – the palette leaves the `state` category out.
|
||||
* One state of a state machine: a node whose name is its title. Its graph is the machine asset's,
|
||||
* never a behavior's – the palette leaves the `state` category out.
|
||||
*/
|
||||
export const State: NodeDef = {
|
||||
type: STATE,
|
||||
label: 'State',
|
||||
category: 'state',
|
||||
description: 'One state of a state machine.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
inputs: [{ key: STATE_IN, label: '', type: 'exec' }],
|
||||
outputs: [{ key: STATE_OUT, label: '', type: 'exec' }],
|
||||
params: [flatDef(STATE_NAME_PARAM, 'text', { label: 'Name', value: 'State' })],
|
||||
transitionEdges: true,
|
||||
derivePorts: ({ node }) => ({ label: stateName(node) }),
|
||||
};
|
||||
|
||||
@@ -38,6 +47,31 @@ export function statesOf(graph: Graph): MachineState[] {
|
||||
return graph.nodes.filter(isStateNode).map((node) => ({ id: node.id, name: stateName(node) }));
|
||||
}
|
||||
|
||||
/** The transitions of a machine's graph: every edge between two states, in edge order. */
|
||||
export function transitionsOf(graph: Graph): MachineTransition[] {
|
||||
const states = new Set(graph.nodes.filter(isStateNode).map((n) => n.id));
|
||||
return graph.edges
|
||||
.filter((e) => states.has(e.from.node) && states.has(e.to.node))
|
||||
.map((e) => ({ from: e.from.node, to: e.to.node, name: e.label ?? '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `State transition` moves from `current`: along the transition of that name leaving it. The
|
||||
* name is matched against the current state alone, so one node asking for "alarm" moves every state
|
||||
* that has an "alarm". Nothing happens when the state has no such transition, or two of the name –
|
||||
* with nothing drawn to pick between them, choosing either would be a guess the graph never made.
|
||||
*/
|
||||
export function transitionTo(machine: StateMachine, current: string, name: string): string {
|
||||
const out = machine.transitions.filter((t) => t.from === current && t.name === name);
|
||||
const targets = [...new Set(out.map((t) => t.to))];
|
||||
return targets.length === 1 ? targets[0]! : '';
|
||||
}
|
||||
|
||||
/** The names a machine's transitions carry, deduped and in edge order – what a node may ask for. */
|
||||
export function transitionNames(machine: StateMachine | null): string[] {
|
||||
return [...new Set((machine?.transitions ?? []).map((t) => t.name).filter((name) => name))];
|
||||
}
|
||||
|
||||
/** What a state placed from the palette is called: "State 3" for the third. */
|
||||
export function nextStateName(graph: Graph): string {
|
||||
return `${State.label} ${statesOf(graph).length + 1}`;
|
||||
|
||||
@@ -83,8 +83,8 @@ export interface AnimationAsset extends AssetBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph holds one `State` node per state and nothing else; a behavior reads the machine's
|
||||
* current state through `State branch` and moves it through `Set state`.
|
||||
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
|
||||
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
|
||||
*/
|
||||
export interface StateMachineAsset extends AssetBase {
|
||||
type: 'statemachine';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GraphLookup } from '../graph/lookup';
|
||||
import { statesOf } from '../graph/stateMachine';
|
||||
import { statesOf, transitionsOf } from '../graph/stateMachine';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
@@ -16,7 +16,12 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
return asset ? { name: asset.name, states: statesOf(asset.graph) } : null;
|
||||
if (!asset) return null;
|
||||
return {
|
||||
name: asset.name,
|
||||
states: statesOf(asset.graph),
|
||||
transitions: transitionsOf(asset.graph),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -339,6 +339,8 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
|
||||
to: { node: to.node, port: to.port },
|
||||
};
|
||||
if (e.folded === true) edge.folded = true;
|
||||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||||
if (label) edge.label = label;
|
||||
graph.edges.push(edge);
|
||||
}
|
||||
return graph;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js';
|
||||
import { NameTransitionCommand } from '../core/commands/graphCommands';
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
@@ -287,6 +288,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(),
|
||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||
/** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||||
hint: () => editor.stateMachineEditor.canvas.shownHint(),
|
||||
/** Names a transition the way the inspector row does, for steps that need one already named. */
|
||||
nameTransition: (edgeId: string, name: string) =>
|
||||
state.execute(new NameTransitionCommand(editor.stateMachineEditor.currentAssetId, edgeId, name)),
|
||||
dropTarget: () => editor.stateMachineEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => editor.stateMachineEditor.canvas.danglingEdgeVisible(),
|
||||
emptySpot: () => editor.stateMachineEditor.canvas.emptySpotGlobal(),
|
||||
selected: () => state.selectedNodeIds,
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Container, Graphics, Text } from 'pixi.js';
|
||||
import { type GraphEdge, portsCompatible } from '../../core/graph';
|
||||
import { Theme, textStyle, truncate } from '../../ui';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { arrowCorners, arrowShaft, type Point, transitionEnds, transitionOffsets } from './transitions';
|
||||
import {
|
||||
buildFoldMarks,
|
||||
foldedEdgeShown,
|
||||
@@ -17,6 +19,7 @@ import { type NodeView, portColor } from './NodeView';
|
||||
const FOLD_STUB = 16;
|
||||
const FOLD_MARK_RADIUS = 5;
|
||||
const FOLD_LABEL_WIDTH = 96;
|
||||
const TRANSITION_LABEL_WIDTH = 110;
|
||||
const BEAD_RADIUS = 4.5;
|
||||
/** Dash pattern and sampling of an edge hanging on a port that is gone. */
|
||||
const GHOST_DASH = 7;
|
||||
@@ -48,6 +51,9 @@ export class EdgeLayer extends Container {
|
||||
/** Edges the last draw really stroked as a curve – a hidden one is missing from it. */
|
||||
private readonly stroked = new Set<string>();
|
||||
private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = [];
|
||||
/** Sideways shift per transition, so two between the same states do not lie on top of each other. */
|
||||
private offsets = new Map<string, number>();
|
||||
private transitionLabels: { edgeId: string; text: string; x: number; y: number }[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -69,6 +75,12 @@ export class EdgeLayer extends Container {
|
||||
this.ends = [];
|
||||
this.labelCount = 0;
|
||||
this.stroked.clear();
|
||||
this.transitionLabels = [];
|
||||
this.offsets = transitionOffsets(
|
||||
edges
|
||||
.filter((e) => isTransition(e, views))
|
||||
.map((e) => ({ id: e.id, from: e.from.node, to: e.to.node })),
|
||||
);
|
||||
for (const edge of edges) this.drawEdge(edge, views, isHot(edge.id), selected, beads(edge.id));
|
||||
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
|
||||
this.drawFoldMarks();
|
||||
@@ -111,14 +123,25 @@ export class EdgeLayer extends Container {
|
||||
const ghost = out?.ghost === true || inp?.ghost === true;
|
||||
const valid = !ghost && portsCompatible(type, inType);
|
||||
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
|
||||
const start = { x: from.x + a.x, y: from.y + a.y };
|
||||
const end = { x: to.x + b.x, y: to.y + b.y };
|
||||
const pins = {
|
||||
start: { x: from.x + a.x, y: from.y + a.y },
|
||||
end: { x: to.x + b.x, y: to.y + b.y },
|
||||
};
|
||||
const transition = isTransition(edge, views)
|
||||
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
|
||||
: null;
|
||||
const { start, end } = transition ?? pins;
|
||||
if (edge.folded) {
|
||||
this.addFoldEnd(edge, views, start, true, to.def.label, color, hot);
|
||||
this.addFoldEnd(edge, views, end, false, from.def.label, color, hot);
|
||||
// The fold mark belongs on the port, whatever the line between the nodes does.
|
||||
this.addFoldEnd(edge, views, pins.start, true, to.def.label, color, hot);
|
||||
this.addFoldEnd(edge, views, pins.end, false, from.def.label, color, hot);
|
||||
if (!foldedEdgeShown(edge, selected, hot)) return;
|
||||
}
|
||||
if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
|
||||
const shaft = transition ? arrowShaft(start, end) : end;
|
||||
if (ghost && transition)
|
||||
dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP);
|
||||
else if (ghost) dashedBezier(this.curves, start.x, start.y, end.x, end.y, GHOST_DASH, GHOST_GAP);
|
||||
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
|
||||
else bezier(this.curves, start.x, start.y, end.x, end.y);
|
||||
this.curves.stroke({
|
||||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||||
@@ -126,6 +149,8 @@ export class EdgeLayer extends Container {
|
||||
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
|
||||
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
|
||||
});
|
||||
if (transition) arrowHead(this.curves, start, end, color);
|
||||
if (transition && edge.label) this.drawTransitionLabel(edge, start, end);
|
||||
this.stroked.add(edge.id);
|
||||
for (const progress of beads) {
|
||||
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
|
||||
@@ -138,6 +163,23 @@ export class EdgeLayer extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
/** The transition's name at the middle of its arrow, on a plate so the line does not run through it. */
|
||||
private drawTransitionLabel(edge: GraphEdge, start: Point, end: Point): void {
|
||||
const label = this.nextLabel();
|
||||
label.text = truncate(edge.label ?? '', textStyle('small'), TRANSITION_LABEL_WIDTH);
|
||||
const x = (start.x + end.x) / 2 - label.width / 2;
|
||||
const y = (start.y + end.y) / 2 - label.height / 2;
|
||||
label.position.set(x, y);
|
||||
this.chips
|
||||
.roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3)
|
||||
.fill({ color: Theme.colors.node, alpha: 0.85 });
|
||||
this.transitionLabels.push({ edgeId: edge.id, text: label.text, x, y });
|
||||
}
|
||||
|
||||
drawnTransitionLabels(): readonly { edgeId: string; text: string; x: number; y: number }[] {
|
||||
return this.transitionLabels;
|
||||
}
|
||||
|
||||
private addFoldEnd(
|
||||
edge: GraphEdge,
|
||||
views: ReadonlyMap<string, NodeView>,
|
||||
@@ -195,6 +237,21 @@ export class EdgeLayer extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
function isTransition(edge: GraphEdge, views: ReadonlyMap<string, NodeView>): boolean {
|
||||
return (
|
||||
views.get(edge.from.node)?.def.transitionEdges === true &&
|
||||
views.get(edge.to.node)?.def.transitionEdges === true
|
||||
);
|
||||
}
|
||||
|
||||
/** The filled triangle at the target end of a transition. */
|
||||
export function arrowHead(g: Graphics, start: Point, end: Point, color: number): void {
|
||||
const corners = arrowCorners(start, end);
|
||||
if (!corners) return;
|
||||
const [tip, left, right] = corners;
|
||||
g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color);
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points. */
|
||||
export function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void {
|
||||
const dx = curveReach(x1, x2);
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
portTypeOf,
|
||||
resolveDef,
|
||||
RUN_BEHAVIOR,
|
||||
STATE_IN,
|
||||
STATE_OUT,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
@@ -44,16 +46,19 @@ import {
|
||||
endDrag,
|
||||
onDragChange,
|
||||
pressCount,
|
||||
type MenuItem,
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
ui,
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { type EditorState, nodeSelection } from '../state';
|
||||
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
|
||||
import { bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, transitionPreview } from './transitions';
|
||||
import { openLinkMenu, openNodeMenu } from './graphMenus';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
@@ -112,6 +117,10 @@ export class GraphCanvas extends Widget {
|
||||
/** A finished drag whose palette is still open – drawn like the running one, so the connection
|
||||
* being made stays visible while the node type is chosen. */
|
||||
private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null;
|
||||
/** A transition begun from a state's menu, waiting for the click that names its target. It takes
|
||||
* the shape of a drag because it is one in everything but the held button – that way the running
|
||||
* preview draws it without a second code path. `release` takes the Escape handler back off. */
|
||||
private armed: { from: PortSide; pointer: { x: number; y: number }; release: () => void } | null = null;
|
||||
/** Where the running drag would land, recomputed on every move so the drop holds no surprise. */
|
||||
private dropTarget: PortSide | null = null;
|
||||
/** Whether the last redraw actually stroked the dangling edge. */
|
||||
@@ -173,6 +182,8 @@ export class GraphCanvas extends Widget {
|
||||
/** Switching refits the view, since the next graph is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
// Its source node is in the graph being left, so the click that finished it would land nowhere.
|
||||
this.cancelTransition();
|
||||
this.assetId = assetId;
|
||||
this.sync();
|
||||
this.needsFit = true;
|
||||
@@ -216,6 +227,7 @@ export class GraphCanvas extends Widget {
|
||||
if (!asset) {
|
||||
// An asset that was deleted (or undone away) must not linger as a graph.
|
||||
this.assetId = '';
|
||||
this.cancelTransition();
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.redraw();
|
||||
@@ -317,6 +329,11 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
if (e.target !== this) return;
|
||||
// Away from every state, so there is nothing to transition to: the click drops the attempt.
|
||||
if (this.armed && (e.button === 0 || e.button === 2)) {
|
||||
this.finishTransition(e.global.x, e.global.y, false);
|
||||
return;
|
||||
}
|
||||
// Shift-drag on empty space pans as well, as it does in the scene view.
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
startPan();
|
||||
@@ -346,6 +363,12 @@ export class GraphCanvas extends Widget {
|
||||
if (e.button === 1) return;
|
||||
if (!this.assetId) return;
|
||||
e.stopPropagation();
|
||||
// An armed transition eats the next click whole: on another state it lands, anywhere else it
|
||||
// is dropped. Selecting or dragging the node underneath would be a second meaning for one click.
|
||||
if (this.armed && (e.button === 0 || e.button === 2)) {
|
||||
this.finishTransition(e.global.x, e.global.y, e.button === 0);
|
||||
return;
|
||||
}
|
||||
const local = view.toLocal(e.global);
|
||||
const hit = view.portAt(local.x, local.y);
|
||||
if (e.button === 2) {
|
||||
@@ -356,7 +379,13 @@ export class GraphCanvas extends Widget {
|
||||
} else {
|
||||
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
|
||||
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
|
||||
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y);
|
||||
const at = this.toLocal(e.global);
|
||||
const graphPoint = this.screenToGraph(at.x, at.y);
|
||||
const extras: MenuItem[] =
|
||||
view.def.transitionEdges === true
|
||||
? [{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }]
|
||||
: [];
|
||||
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -446,6 +475,10 @@ export class GraphCanvas extends Widget {
|
||||
this.connecting.pointer = g;
|
||||
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
|
||||
this.redraw();
|
||||
} else if (this.armed) {
|
||||
this.armed.pointer = g;
|
||||
this.dropTarget = this.armedTarget(e.global.x, e.global.y);
|
||||
this.redraw();
|
||||
} else if (this.marquee) {
|
||||
this.marquee.current = g;
|
||||
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
|
||||
@@ -607,6 +640,18 @@ export class GraphCanvas extends Widget {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Null unless the drag started on a state: only those draw as a straight arrow. */
|
||||
private transitionPreviewOf(
|
||||
from: PortSide,
|
||||
target: PortSide | null,
|
||||
pointer: Point,
|
||||
): { start: Point; end: Point } | null {
|
||||
const source = this.views.get(from.nodeId);
|
||||
if (source?.def.transitionEdges !== true) return null;
|
||||
const to = target ? this.views.get(target.nodeId) : undefined;
|
||||
return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer);
|
||||
}
|
||||
|
||||
private nodeUnder(globalX: number, globalY: number): string | null {
|
||||
for (const view of this.topDownViews()) {
|
||||
const p = view.toLocal({ x: globalX, y: globalY });
|
||||
@@ -666,6 +711,59 @@ export class GraphCanvas extends Widget {
|
||||
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
|
||||
}
|
||||
|
||||
/** Begin a transition out of this state; the next click on another one completes it. */
|
||||
private armTransition(nodeId: string, pointer: { x: number; y: number }): void {
|
||||
this.cancelTransition();
|
||||
const handlers = ui().focus.globalHandlers;
|
||||
const onKey = (event: KeyboardEvent): boolean => {
|
||||
if (event.key !== 'Escape' || !this.armed) return false;
|
||||
this.cancelTransition();
|
||||
this.redraw();
|
||||
return true;
|
||||
};
|
||||
handlers.push(onKey);
|
||||
this.armed = {
|
||||
from: { nodeId, port: STATE_OUT, isOutput: true },
|
||||
pointer,
|
||||
release: () => {
|
||||
const at = handlers.indexOf(onKey);
|
||||
if (at >= 0) handlers.splice(at, 1);
|
||||
},
|
||||
};
|
||||
this.dropTarget = null;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private cancelTransition(): void {
|
||||
this.armed?.release();
|
||||
this.armed = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an armed transition would land: any other state under the pointer. A state cannot
|
||||
* transition to itself, which is what `ConnectCommand` says about every node.
|
||||
*/
|
||||
private armedTarget(globalX: number, globalY: number): PortSide | null {
|
||||
const from = this.armed?.from;
|
||||
if (!from) return null;
|
||||
const node = this.nodeUnder(globalX, globalY);
|
||||
if (!node || node === from.nodeId) return null;
|
||||
const view = this.views.get(node);
|
||||
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
|
||||
}
|
||||
|
||||
/** Finish or abandon an armed transition. True when the click belonged to it. */
|
||||
private finishTransition(globalX: number, globalY: number, connect: boolean): boolean {
|
||||
const armed = this.armed;
|
||||
if (!armed) return false;
|
||||
const target = connect ? this.armedTarget(globalX, globalY) : null;
|
||||
this.cancelTransition();
|
||||
this.dropTarget = null;
|
||||
if (target) this.connect(armed.from, target);
|
||||
this.redraw();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
|
||||
private setHint(text: string): void {
|
||||
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
|
||||
@@ -688,7 +786,15 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const playing = this.state.mode === 'play';
|
||||
this.setHint(asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : '');
|
||||
this.setHint(
|
||||
this.armed
|
||||
? 'Click the state to transition to – anywhere else, or Escape, cancels'
|
||||
: asset.graph.nodes.length === 0
|
||||
? 'Right-click to add a node'
|
||||
: playing
|
||||
? 'live'
|
||||
: '',
|
||||
);
|
||||
|
||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||
const now = this.timeProvider();
|
||||
@@ -721,14 +827,21 @@ export class GraphCanvas extends Widget {
|
||||
|
||||
this.tempEdge.clear();
|
||||
this.tempEdgeShown = false;
|
||||
const drag = this.connecting ?? this.pendingDrop;
|
||||
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
|
||||
const a = drag && this.anchorPoint(drag.from);
|
||||
if (drag && a) {
|
||||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||||
const [from, to] = drag.from.isOutput ? [a, b] : [b, a];
|
||||
bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||
// Dragged off a state, the line already looks like the transition it is about to become.
|
||||
const preview = this.transitionPreviewOf(drag.from, target, drag.pointer);
|
||||
const ends = preview ? [preview.start, preview.end] : [a, b];
|
||||
const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point];
|
||||
if (preview) {
|
||||
const shaft = arrowShaft(from, to);
|
||||
this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y);
|
||||
} else bezier(this.tempEdge, from.x, from.y, to.x, to.y);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
if (preview) arrowHead(this.tempEdge, from, to, c.textMuted);
|
||||
this.tempEdgeShown = true;
|
||||
}
|
||||
|
||||
@@ -799,6 +912,23 @@ export class GraphCanvas extends Widget {
|
||||
return this.edgeLayer.wasStroked(edgeId);
|
||||
}
|
||||
|
||||
/** The name drawn on a transition, or null when nothing is drawn on it. */
|
||||
transitionLabelDrawn(edgeId: string): string | null {
|
||||
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
|
||||
}
|
||||
|
||||
/** Whether a pin can be grabbed on this node at all – false for a state, whose ports are hidden. */
|
||||
portGrabbable(nodeId: string, port: string, isOutput: boolean): boolean {
|
||||
const view = this.views.get(nodeId);
|
||||
const p = view?.portPosition(port, isOutput);
|
||||
return view !== undefined && p !== null && p !== undefined && view.portAt(p.x, p.y) !== null;
|
||||
}
|
||||
|
||||
/** The line at the bottom of the canvas – what tells the user an armed transition is waiting. */
|
||||
shownHint(): string {
|
||||
return this.hint.text;
|
||||
}
|
||||
|
||||
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
|
||||
return this.edgeLayer
|
||||
.drawnMarks()
|
||||
|
||||
@@ -199,7 +199,9 @@ export class NodeView extends Widget {
|
||||
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
|
||||
}
|
||||
|
||||
/** Rows of ports, which set the node's height – none on a state, whose ports are never drawn. */
|
||||
private rowCount(): number {
|
||||
if (this.def.transitionEdges === true) return 0;
|
||||
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||||
}
|
||||
|
||||
@@ -275,9 +277,12 @@ export class NodeView extends Widget {
|
||||
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||
}
|
||||
|
||||
/** Every port with its local centre – none while collapsed, so none can be grabbed. */
|
||||
/**
|
||||
* Every port with its local centre – none while collapsed, and none on a state, so neither can
|
||||
* be drawn or grabbed. `portPosition` stays open either way: the edges still need their anchor.
|
||||
*/
|
||||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||||
if (this.collapsed) return;
|
||||
if (this.collapsed || this.def.transitionEdges === true) return;
|
||||
for (const [list, isOutput] of [
|
||||
[this.def.inputs, false],
|
||||
[this.def.outputs, true],
|
||||
|
||||
@@ -13,6 +13,7 @@ export function openNodeMenu(
|
||||
nodes: readonly GraphNode[],
|
||||
x: number,
|
||||
y: number,
|
||||
extras: readonly MenuItem[] = [],
|
||||
): void {
|
||||
const ids = state.selectedNodeIds;
|
||||
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
|
||||
@@ -24,6 +25,7 @@ export function openNodeMenu(
|
||||
const expanded = nodes.filter((n) => !n.collapsed);
|
||||
const collapsed = nodes.filter((n) => n.collapsed);
|
||||
new Menu([
|
||||
...extras,
|
||||
...fold(expanded, 'Collapse', true),
|
||||
...fold(collapsed, 'Expand', false),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Geometry of a transition: the straight arrow drawn between two states. It runs border to border
|
||||
* rather than pin to pin, because a diagonal line into a port would pass under the target node and
|
||||
* take its arrowhead with it – the usual curve never does, since it swings in horizontally.
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Length of an arrowhead along the line, and half its width across. */
|
||||
export const ARROW = 10;
|
||||
export const ARROW_HALF_WIDTH = 4.5;
|
||||
/** Air between a node's border and the end of the arrow touching it. */
|
||||
export const BORDER_GAP = 7;
|
||||
/** How far apart two transitions between the same pair of states are pushed. */
|
||||
export const PARALLEL_SEP = 14;
|
||||
|
||||
export function centreOf(box: Rect): Point {
|
||||
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
|
||||
}
|
||||
|
||||
/** Null when the two points coincide: there is no direction to draw along. */
|
||||
export function direction(a: Point, b: Point): Point | null {
|
||||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
return length === 0 ? null : { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
|
||||
}
|
||||
|
||||
/** Where a ray leaving the box's centre crosses its border. */
|
||||
export function boxExit(box: Rect, dir: Point): Point {
|
||||
const centre = centreOf(box);
|
||||
const tx = dir.x === 0 ? Infinity : box.w / 2 / Math.abs(dir.x);
|
||||
const ty = dir.y === 0 ? Infinity : box.h / 2 / Math.abs(dir.y);
|
||||
const t = Math.min(tx, ty);
|
||||
return { x: centre.x + dir.x * t, y: centre.y + dir.y * t };
|
||||
}
|
||||
|
||||
/** The normal of a direction, pointing to its right. */
|
||||
function normal(dir: Point): Point {
|
||||
return { x: -dir.y, y: dir.x };
|
||||
}
|
||||
|
||||
function shift(point: Point, out: Point, along: number, n: Point, across: number): Point {
|
||||
return { x: point.x + out.x * along + n.x * across, y: point.y + out.y * along + n.y * across };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two points a transition between these nodes runs between: each a border crossing, pushed
|
||||
* `BORDER_GAP` clear of the node and `offset` sideways. Null when the boxes share a centre.
|
||||
*/
|
||||
export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point; end: Point } | null {
|
||||
const dir = direction(centreOf(from), centreOf(to));
|
||||
if (!dir) return null;
|
||||
const back = { x: -dir.x, y: -dir.y };
|
||||
const n = normal(dir);
|
||||
return {
|
||||
start: shift(boxExit(from, dir), dir, BORDER_GAP, n, offset),
|
||||
end: shift(boxExit(to, back), back, BORDER_GAP, n, offset),
|
||||
};
|
||||
}
|
||||
|
||||
/** One transition as the offsets care about it: which states it joins, under which edge id. */
|
||||
export interface TransitionLink {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far each transition is pushed sideways, so several between the same two states lie next to
|
||||
* each other instead of on top. The fan is computed in the canonical direction of the pair, and a
|
||||
* transition running the other way takes the opposite sign – which is what puts a there-and-back
|
||||
* pair on either side of the line rather than both on the same one.
|
||||
*/
|
||||
export function transitionOffsets(links: readonly TransitionLink[]): Map<string, number> {
|
||||
const groups = new Map<string, TransitionLink[]>();
|
||||
for (const link of links) {
|
||||
const [a, b] = link.from < link.to ? [link.from, link.to] : [link.to, link.from];
|
||||
const key = `${a}\u0000${b}`;
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(link);
|
||||
else groups.set(key, [link]);
|
||||
}
|
||||
const offsets = new Map<string, number>();
|
||||
for (const group of groups.values()) {
|
||||
for (const [i, link] of group.entries()) {
|
||||
const canonical = (i - (group.length - 1) / 2) * PARALLEL_SEP;
|
||||
offsets.set(link.id, link.from <= link.to ? canonical : -canonical);
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line drawn while a transition is being dragged: out of the source's border towards the
|
||||
* pointer, or border to border once the drag has found another state.
|
||||
*/
|
||||
export function transitionPreview(
|
||||
source: Rect,
|
||||
target: Rect | null,
|
||||
pointer: Point,
|
||||
): {
|
||||
start: Point;
|
||||
end: Point;
|
||||
} {
|
||||
if (target) return transitionEnds(source, target) ?? { start: centreOf(source), end: pointer };
|
||||
const dir = direction(centreOf(source), pointer);
|
||||
if (!dir) return { start: centreOf(source), end: pointer };
|
||||
return { start: shift(boxExit(source, dir), dir, BORDER_GAP, normal(dir), 0), end: pointer };
|
||||
}
|
||||
|
||||
/** Where the line stops: at the head's base, so the stroke does not blunt its tip. */
|
||||
export function arrowShaft(start: Point, end: Point): Point {
|
||||
const u = direction(start, end);
|
||||
return u ? { x: end.x - u.x * ARROW, y: end.y - u.y * ARROW } : end;
|
||||
}
|
||||
|
||||
/** The three corners of the arrowhead at the target end, tip first. */
|
||||
export function arrowCorners(start: Point, end: Point): [Point, Point, Point] | null {
|
||||
const u = direction(start, end);
|
||||
if (!u) return null;
|
||||
const base = arrowShaft(start, end);
|
||||
const nx = -u.y * ARROW_HALF_WIDTH;
|
||||
const ny = u.x * ARROW_HALF_WIDTH;
|
||||
return [end, { x: base.x + nx, y: base.y + ny }, { x: base.x - nx, y: base.y - ny }];
|
||||
}
|
||||
@@ -3,13 +3,22 @@ import {
|
||||
declaresPropertyPorts,
|
||||
declaresVariablePorts,
|
||||
edgesInto,
|
||||
edgesOfNode,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
inlineInputDef,
|
||||
inlineInputs,
|
||||
isStateNode,
|
||||
MACHINE_PORT,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_TYPE_PARAM,
|
||||
portSignature,
|
||||
resolveDef,
|
||||
STATE_TRANSITION,
|
||||
stateName,
|
||||
TRANSITION_PARAM,
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
@@ -21,6 +30,7 @@ import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildPortSection, buildPropertyPortSection, buildVariablePortSection } from './portSection';
|
||||
import { listOpener, 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. */
|
||||
@@ -39,11 +49,24 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`).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] ?? '')}`;
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${variablesOf(
|
||||
asset,
|
||||
)
|
||||
.map((v) => v.key)
|
||||
.join(',')}|${target}`;
|
||||
.join(',')}|${target}|${transitions}`;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -79,8 +102,13 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
|
||||
if (!ownSettings && def.params.length > 0) {
|
||||
addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) addSetting(param);
|
||||
for (const param of def.params) {
|
||||
if (param.key === TRANSITION_PARAM && node.type === STATE_TRANSITION)
|
||||
buildTransitionPicker(host, assetId, node, asset.graph);
|
||||
else addSetting(param);
|
||||
}
|
||||
}
|
||||
if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph);
|
||||
|
||||
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
|
||||
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
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 – the only handle they have, since a transition
|
||||
* hangs on ports the canvas never draws and so cannot be clicked out there. */
|
||||
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 another one, on the given side. */
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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<string>();
|
||||
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 Label(outgoing ? '→' : '←', { style: 'muted' }));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import type { EditorState } from '../state';
|
||||
const INFO_WIDTH = 90;
|
||||
|
||||
/**
|
||||
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node,
|
||||
* so placing (right-click), moving, snapping, renaming, deleting and undo are the graph's.
|
||||
* Transitions are a later step.
|
||||
* The states of one state machine, on the graph canvas the Behavior tab uses: a state is a node and
|
||||
* a transition an edge, so placing (right-click), moving, snapping, renaming, connecting, deleting
|
||||
* and undo are all the graph's.
|
||||
*/
|
||||
export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
Reference in new issue
Block a user