Files
feuerwerk/src/editor/inspector/transitionSection.ts
T
adminandClaude Opus 5.5 c5ba21533d
game-maker / gate (push) Successful in 2m37s
game-maker / suite (push) Successful in 13m10s
Pick a link on its line and delete it from there (#289)
A link could only be removed through the menu of its port, and the
line itself took no input. A transition of a state machine could only
be deleted in the list of the inspector. Now the line is the handle,
in a behavior's graph and between states alike.

- A click on a line selects that link and draws a halo behind it. A
  press counts within 6 screen pixels of the line, and where two cross
  the one drawn last wins.
- Ctrl-click or Shift-click adds a link or takes it away. The rubber
  band takes every line it crosses, into the same selection as the
  nodes and boxes it caught.
- Delete or Backspace removes what is picked as one undo step. A
  right-click on a line offers Hide link and Delete link.
- The inspector shows a picked link: the node and port at each end and
  the type it carries, with Hide link and Delete link below. For
  several it says how many.
- The arrow of a transition is picked the same way, on its name too.
  Delete or a right-click with Delete transition removes it.
- The menu of a port is unchanged, Disconnect included, as answered in
  the issue.
- editor/graph/edgeHit.ts holds the hit test as pure functions, and
  editor/inspector/edgeSection.ts builds the section. The selection
  has a new kind edge, and node and annotation carry edgeIds.

Decided here:

- A hidden link has no line and is never picked this way, also while
  it is shown on loan. Its mark at the port stays its handle.
- The menu of a line speaks about the whole selection, like the menu
  of a box. With nodes or boxes held alongside it reads Delete N
  items.
- Hiding picked links lets go of them, since no line is left.
- A link wins over the box it crosses, and a mark wins over a link.
- A transition has no Hide entry.
- With nodes or boxes in the selection the inspector keeps showing
  the node or box, not the link.

Tests:
- Gate: 201 files, 2982 tests (2967 + 15), exit 0
- e2e, full run: 1635 checks passed (1629 + 6), exit 0
- Smokes: 24 / 19 / 21, exit 0
- The full run was done for exactly this state.
- 15 unit tests in tests/editor/edge-selection.test.ts
- Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7
  in module 26
- 12 unit counter-probes all fail tests, run on stein2
- Three browser counter-probes fail, the build went through each
  time: no hit on the line, no halo, a band that skips lines
- Screenshots of a picked link and a picked transition looked at
  before the last build, none after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:51:15 +02:00

134 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 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);
}
}