A state can transition to itself: make transition and a second click on the same state connects a self transition, taking it fires On state exited and then On state entered every time, for local and global machines, while Set state into the current state stays silent as before; only states may connect to themselves, every other node is still refused, one self transition per state; the loop is drawn over the middle of the top edge with its arrow pointing down and its name at the apex, the preview already shows it while armed, and the inspector lists it once under Transitions out; new test hook transitionLabelAt, e2e 26e5 (#163)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc285af10a
commit
010b6a7f6c
12 files changed
+355
-33
No files matched your search
@@ -384,6 +384,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
|
||||
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
|
||||
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
|
||||
transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(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) =>
|
||||
|
||||
@@ -2,7 +2,16 @@ 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 {
|
||||
arrowCorners,
|
||||
arrowShaft,
|
||||
type Loop,
|
||||
loopPoint,
|
||||
type Point,
|
||||
selfLoop,
|
||||
transitionEnds,
|
||||
transitionOffsets,
|
||||
} from './transitions';
|
||||
import {
|
||||
buildFoldMarks,
|
||||
foldedEdgeShown,
|
||||
@@ -127,18 +136,24 @@ export class EdgeLayer extends Container {
|
||||
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 loop = from === to && isTransition(edge, views) ? selfLoop(from) : null;
|
||||
const transition = loop
|
||||
? { start: loop.start, end: loop.end }
|
||||
: isTransition(edge, views)
|
||||
? transitionEnds(from, to, this.offsets.get(edge.id) ?? 0)
|
||||
: null;
|
||||
const { start, end } = transition ?? pins;
|
||||
// The arrowhead follows the direction the line arrives in, which on the loop is its last curve.
|
||||
const approach = loop?.c2 ?? start;
|
||||
if (edge.folded) {
|
||||
// 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;
|
||||
}
|
||||
const shaft = transition ? arrowShaft(start, end) : end;
|
||||
if (ghost && transition)
|
||||
const shaft = transition ? arrowShaft(approach, end) : end;
|
||||
if (loop) loopPath(this.curves, loop);
|
||||
else 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);
|
||||
@@ -149,11 +164,17 @@ 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);
|
||||
if (transition) arrowHead(this.curves, approach, end, color);
|
||||
if (transition && edge.label)
|
||||
this.drawTransitionLabel(
|
||||
edge,
|
||||
loop ? loopPoint(loop, 0.5) : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 },
|
||||
);
|
||||
this.stroked.add(edge.id);
|
||||
for (const progress of beads) {
|
||||
const { x, y } = curvePoint(start.x, start.y, end.x, end.y, progress);
|
||||
const { x, y } = loop
|
||||
? loopPoint(loop, progress)
|
||||
: curvePoint(start.x, start.y, end.x, end.y, progress);
|
||||
// Light with a dark rim: a bead in the highlight colour would vanish on the edge it lights up.
|
||||
this.beads
|
||||
.circle(x, y, BEAD_RADIUS)
|
||||
@@ -164,11 +185,11 @@ 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 {
|
||||
private drawTransitionLabel(edge: GraphEdge, middle: 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;
|
||||
const x = middle.x - label.width / 2;
|
||||
const y = middle.y - label.height / 2;
|
||||
label.position.set(x, y);
|
||||
this.chips
|
||||
.roundRect(x - 3, y - 1, label.width + 6, label.height + 2, 3)
|
||||
@@ -252,6 +273,19 @@ export function arrowHead(g: Graphics, start: Point, end: Point, color: number):
|
||||
g.moveTo(tip.x, tip.y).lineTo(left.x, left.y).lineTo(right.x, right.y).closePath().fill(color);
|
||||
}
|
||||
|
||||
/** The loop of a state's transition into itself, stopped at the arrowhead's base like a straight one. */
|
||||
export function loopPath(g: Graphics, loop: Loop): void {
|
||||
const shaft = arrowShaft(loop.c2, loop.end);
|
||||
g.moveTo(loop.start.x, loop.start.y).bezierCurveTo(
|
||||
loop.c1.x,
|
||||
loop.c1.y,
|
||||
loop.c2.x,
|
||||
loop.c2.y,
|
||||
shaft.x,
|
||||
shaft.y,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -83,8 +83,8 @@ import {
|
||||
type Travellers,
|
||||
} from './annotationDrag';
|
||||
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
|
||||
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, transitionPreview } from './transitions';
|
||||
import { arrowHead, bezier, EdgeLayer, loopPath } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions';
|
||||
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
|
||||
import { NodePalette, type PaletteAction } from './NodePalette';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
@@ -1008,15 +1008,11 @@ export class GraphCanvas extends Widget {
|
||||
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.
|
||||
*/
|
||||
/** Where an armed transition would land: the state under the pointer, its own source included. */
|
||||
private armedTarget(globalX: number, globalY: number): PortSide | null {
|
||||
const from = this.armed?.from;
|
||||
if (!from) return null;
|
||||
if (!this.armed) return null;
|
||||
const node = this.nodeUnder(globalX, globalY);
|
||||
if (!node || node === from.nodeId) return null;
|
||||
if (!node) return null;
|
||||
const view = this.views.get(node);
|
||||
return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null;
|
||||
}
|
||||
@@ -1057,7 +1053,7 @@ export class GraphCanvas extends Widget {
|
||||
const playing = this.state.mode === 'play';
|
||||
this.setHint(
|
||||
this.armed
|
||||
? 'Click the state to transition to – anywhere else, or Escape, cancels'
|
||||
? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels'
|
||||
: asset.graph.nodes.length === 0
|
||||
? 'Right-click to add a node'
|
||||
: playing
|
||||
@@ -1107,7 +1103,14 @@ export class GraphCanvas extends Widget {
|
||||
this.tempEdgeShown = false;
|
||||
const drag = this.connecting ?? this.pendingDrop ?? this.armed;
|
||||
const a = drag && this.anchorPoint(drag.from);
|
||||
if (drag && a) {
|
||||
const home = drag && target?.nodeId === drag.from.nodeId ? this.views.get(target.nodeId) : undefined;
|
||||
if (home?.def.transitionEdges === true) {
|
||||
const loop = selfLoop(home);
|
||||
loopPath(this.tempEdge, loop);
|
||||
this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 });
|
||||
arrowHead(this.tempEdge, loop.c2, loop.end, c.textMuted);
|
||||
this.tempEdgeShown = true;
|
||||
} else if (drag && a) {
|
||||
// Snapping to the resolved port makes the prediction the ring shows unmistakable.
|
||||
const b = this.anchorPoint(target) ?? drag.pointer;
|
||||
// Dragged off a state, the line already looks like the transition it is about to become.
|
||||
@@ -1197,6 +1200,12 @@ export class GraphCanvas extends Widget {
|
||||
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
|
||||
}
|
||||
|
||||
/** Where that name is drawn, its top-left corner on screen – null when nothing is drawn. */
|
||||
transitionLabelAt(edgeId: string): { x: number; y: number } | null {
|
||||
const label = this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId);
|
||||
return label ? globalPoint(this.edgeLayer, label) : 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);
|
||||
|
||||
@@ -67,6 +67,44 @@ export function transitionEnds(from: Rect, to: Rect, offset = 0): { start: Point
|
||||
};
|
||||
}
|
||||
|
||||
/** How far apart a self-loop's two ends sit on the node's top edge, and how high it rises. */
|
||||
const LOOP_SPREAD = 14;
|
||||
const LOOP_RISE = 44;
|
||||
|
||||
/** A transition from a state back into itself: one cubic curve over the node's top edge. */
|
||||
export interface Loop {
|
||||
start: Point;
|
||||
c1: Point;
|
||||
c2: Point;
|
||||
end: Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* The loop over the middle of the box, left end out and right end in, both `BORDER_GAP` clear of
|
||||
* the border like every other transition. The control points lean outwards, so the curve bulges
|
||||
* wider than its feet and reads as a loop rather than a hump.
|
||||
*/
|
||||
export function selfLoop(box: Rect): Loop {
|
||||
const x = box.x + box.w / 2;
|
||||
const y = box.y - BORDER_GAP;
|
||||
return {
|
||||
start: { x: x - LOOP_SPREAD, y },
|
||||
c1: { x: x - LOOP_SPREAD * 2.5, y: y - LOOP_RISE },
|
||||
c2: { x: x + LOOP_SPREAD * 2.5, y: y - LOOP_RISE },
|
||||
end: { x: x + LOOP_SPREAD, y },
|
||||
};
|
||||
}
|
||||
|
||||
/** The point at `t` along the loop, 0 at its start and 1 at the arrow's tip. */
|
||||
export function loopPoint(loop: Loop, t: number): Point {
|
||||
const u = 1 - t;
|
||||
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
||||
return {
|
||||
x: a * loop.start.x + b * loop.c1.x + c * loop.c2.x + d * loop.end.x,
|
||||
y: a * loop.start.y + b * loop.c1.y + c * loop.c2.y + d * loop.end.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** One transition as the offsets care about it: which states it joins, under which edge id. */
|
||||
export interface TransitionLink {
|
||||
id: string;
|
||||
|
||||
@@ -80,12 +80,13 @@ export function buildTransitionSection(
|
||||
);
|
||||
}
|
||||
|
||||
/** Edges joining this state to another one, on the given side. */
|
||||
/** 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);
|
||||
return mine === nodeId && far !== undefined && isStateNode(far) && (outgoing || other !== nodeId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user