diff --git a/README.md b/README.md index cd4cc42e..752fdecd 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1629 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1635 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 33fd8ddf..24885bd5 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -14,7 +14,7 @@ import { import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; import { RemoveFrameCommand } from '../core/commands/frameCommands'; -import { RemoveNodeCommand } from '../core/commands/graphCommands'; +import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands'; import type { AssetType } from '../core/model/AssetTypes'; @@ -36,7 +36,7 @@ import { findUsages, } from '../core/model/usages'; import { Dialog } from '../ui'; -import { type EditorState, objectSelection } from './state'; +import { type EditorState, graphSelection, objectSelection } from './state'; /** * What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link @@ -107,19 +107,47 @@ export function removeNodes(state: EditorState, assetId: string, nodeIds: readon return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands)); } +/** What a link is called in this graph: between states it is a transition. */ +export function linkNoun(state: EditorState, assetId: string): string { + return findGraphAsset(state.project, assetId)?.type === 'statemachine' ? 'transition' : 'link'; +} + /** - * Deletes everything a graph selection holds – nodes and boxes alike – as one undo step, since one - * band caught them and one keypress asked for them to go. Boxes still take none of their nodes. + * Hides the links as one undo step and lets go of them: a hidden link has no line left to be + * picked on, its mark at the port is the handle from then on. + */ +export function hideLinks(state: EditorState, assetId: string, edgeIds: readonly string[]): boolean { + const commands = edgeIds.map((id) => new FoldEdgeCommand(assetId, id, true)); + if ( + commands.length === 0 || + !state.execute(oneStep(commands.length === 1 ? 'Hide link' : 'Hide links', commands)) + ) + return false; + state.select( + graphSelection(assetId, state.selectedNodeIds, state.selectedAnnotationIds) ?? { kind: 'asset', assetId }, + ); + return true; +} + +/** + * Deletes everything a graph selection holds – nodes, boxes and links alike – as one undo step, + * since one band caught them and one keypress asked for them to go. Boxes still take none of + * their nodes. Links go first, so one at a node that goes too is not asked for twice. */ export function removeGraphSelection( state: EditorState, assetId: string, nodeIds: readonly string[], annotationIds: readonly string[], + edgeIds: readonly string[] = [], ): boolean { const graph = findGraphAsset(state.project, assetId)?.graph; - const commands: Command[] = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); - let noun = ''; + const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id)); + const commands: Command[] = [ + ...links.map((e) => new DisconnectCommand(assetId, e.id)), + ...nodeIds.map((id) => new RemoveNodeCommand(assetId, id)), + ]; + let noun = links.length === 1 ? linkNoun(state, assetId) : ''; for (const id of annotationIds) { const found = graph && findAnnotation(graph, id); if (!found) continue; @@ -151,10 +179,13 @@ export function removeSelection(state: EditorState): boolean { removeObjects(state, s.holderId, s.objectIds); return true; case 'node': - removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds); + removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds, s.edgeIds); return true; case 'annotation': - removeGraphSelection(state, s.assetId, [], s.annotationIds); + removeGraphSelection(state, s.assetId, [], s.annotationIds, s.edgeIds); + return true; + case 'edge': + removeGraphSelection(state, s.assetId, [], [], s.edgeIds); return true; case 'frame': state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 49d9327f..8fcb0059 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -44,6 +44,9 @@ export function graphHooks({ state, editor }: HookContext) { edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId), /** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */ edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId), + /** Halfway along a link on screen, and the links drawn as picked (#289). */ + edgeAt: (edgeId: string) => graphEditor.canvas.edgeGlobal(edgeId), + pickedEdges: () => graphEditor.canvas.pickedEdgesDrawn(), /** The types the node's ports are drawn with, after a shared group had its say (#207). */ portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId), blockFrames: () => graphEditor.canvas.blockFrames(), @@ -72,6 +75,8 @@ export function graphHooks({ state, editor }: HookContext) { portGrabbable: (nodeId: string, port: string, isOutput: boolean) => editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput), edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId), + edgeAt: (edgeId: string) => editor.stateMachineEditor.canvas.edgeGlobal(edgeId), + pickedEdges: () => editor.stateMachineEditor.canvas.pickedEdgesDrawn(), transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId), transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(edgeId), hint: () => editor.stateMachineEditor.canvas.shownHint(), diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index a63a0082..80ec4285 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -26,6 +26,8 @@ import { foldMarkRadius, } from './folding'; import { curvePoint, curveReach } from './beads'; +import { edgeAt, type EdgePath, edgesInBand, pathMiddle } from './edgeHit'; +import type { Band } from '../framing'; import { type NodeView, portColor } from './NodeView'; /** How far a folded edge's stub reaches out of its port. */ @@ -38,6 +40,8 @@ const BEAD_RADIUS = 4.5; const GHOST_DASH = 7; const GHOST_GAP = 5; const DASH_STEPS = 80; +/** How much wider than the link itself the halo behind a picked one is. */ +const PICKED_HALO = 5; /** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */ export interface FoldMark { @@ -75,6 +79,9 @@ export class EdgeLayer extends Container { /** Which ring each loop back into its own state takes, so several nest instead of overlapping. */ private rings = new Map(); private transitionLabels: TransitionLabel[] = []; + /** Every link the last draw stroked, bottom first – what a click on a line is tested against. */ + private paths: EdgePath[] = []; + private picked: ReadonlySet = new Set(); constructor() { super(); @@ -87,7 +94,10 @@ export class EdgeLayer extends Container { isHot: (edgeId: string) => boolean, selected: Set, beads: (edgeId: string) => readonly number[] = () => [], + picked: ReadonlySet = new Set(), ): void { + this.paths = []; + this.picked = picked; this.curves.clear(); this.beads.clear(); this.beadsDrawn = []; @@ -112,6 +122,26 @@ export class EdgeLayer extends Container { return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS); } + /** The link under a point; a hidden one has no line and is never it. */ + edgeAt(g: Point, tolerance: number): string | null { + return edgeAt(this.paths, g, tolerance, this.transitionLabels); + } + + edgesIn(band: Band): string[] { + return edgesInBand(this.paths, band); + } + + /** Halfway along a link as drawn, or null when it has no line. */ + edgeMiddle(edgeId: string): Point | null { + const path = this.paths.find((p) => p.edgeId === edgeId); + return path ? pathMiddle(path) : null; + } + + /** The links the last draw put a halo behind. */ + pickedDrawn(): string[] { + return this.paths.filter((p) => this.picked.has(p.edgeId)).map((p) => p.edgeId); + } + drawnMarks(): readonly FoldMark[] { return this.marks; } @@ -170,6 +200,15 @@ export class EdgeLayer extends Container { if (!foldedEdgeShown(edge, selected, hot)) return; } const shaft = transition ? arrowShaft(approach, end) : end; + const width = hot ? 4 : type === 'exec' ? 2.5 : 2; + const picked = !edge.folded && this.picked.has(edge.id); + if (picked) { + // Solid even behind a dashed link: the halo says "picked", the dashes on top still say "broken". + if (loop) loopPath(this.curves, loop); + 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: width + PICKED_HALO, color: Theme.colors.nodeSelected, alpha: 0.9 }); + } 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); @@ -177,7 +216,7 @@ export class EdgeLayer extends Container { 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, + width, color, // 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), @@ -190,6 +229,13 @@ export class EdgeLayer extends Container { loop ? { x: 1, y: 0 } : (direction(start, end) ?? { x: 1, y: 0 }), ); this.stroked.set(edge.id, color); + // A hidden link shown on loan has its mark as the handle; its line stays out of reach. + if (!edge.folded) + this.paths.push( + loop + ? { edgeId: edge.id, kind: 'loop', loop } + : { edgeId: edge.id, kind: transition ? 'line' : 'curve', start, end }, + ); for (const progress of beads) { const { x, y } = loop ? loopPoint(loop, progress) diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 977bd4c8..6e6c972e 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -43,7 +43,13 @@ import { Widget, zoomFactor, } from '../../ui'; -import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state'; +import { + annotationSelection, + edgeSelection, + type EditorState, + graphSelection, + nodeSelection, +} from '../state'; import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing'; import { annotationBoxes, boxOf } from './annotationDrag'; @@ -53,7 +59,7 @@ import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames'; import { EdgeLayer } from './EdgeLayer'; import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; import type { GraphCanvasHost } from './graphHost'; -import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus'; +import { openAnnotationMenu, openEdgeMenu, openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette, type PaletteAction } from './NodePalette'; import { splitEntries } from './splitMenu'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; @@ -78,6 +84,8 @@ const NEW_GROUP_SIZE = { w: 320, h: 200 }; const NEW_NOTE_SIZE = { w: 220, h: 96 }; /** Air a group keeps around the nodes it was drawn around; the top also holds the title bar. */ const GROUP_MARGIN = 20; +/** How far from a link, in screen pixels, a press still means that link. */ +const EDGE_TOLERANCE = 6; /** * The node graph of one behavior or state machine asset. Which one that is comes from the outside @@ -303,6 +311,8 @@ export class GraphCanvas extends Widget { this.wiring.finish(e.global, false); return; } + // A link runs over the box it crosses, so it is the nearer thing under the pointer. + if (this.pressEdge(e)) return; const id = view.annotation.id; // Grabbing a box that is already part of a wider selection keeps that selection, the way a node // does: otherwise dragging one of several would silently drop the rest first. @@ -450,14 +460,15 @@ export class GraphCanvas extends Widget { this.wiring.finish(e.global, false); return; } + const local = this.toLocal(e.global); + const g = this.screenToGraph(local.x, local.y); + const mark = this.edgeLayer.markAt(g); + if (!mark && this.pressEdge(e)) return; // Shift-drag on empty space pans as well, as it does in the scene view. if (e.button === 0 && e.shiftKey) { startPan(); return; } - const local = this.toLocal(e.global); - const g = this.screenToGraph(local.x, local.y); - const mark = this.edgeLayer.markAt(g); if (e.button === 2) { if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global); else if (this.assetId) this.openPalette(e.global.x, e.global.y, g); @@ -474,6 +485,25 @@ export class GraphCanvas extends Widget { } } + /** + * A press on a link (#289): it selects the link, Ctrl or Shift adds or takes it, and the right + * button opens its menu. False when no link is under the pointer. + */ + private pressEdge(e: FederatedPointerEvent): boolean { + if (!this.assetId || (e.button !== 0 && e.button !== 2)) return false; + const local = this.toLocal(e.global); + const id = this.edgeLayer.edgeAt(this.screenToGraph(local.x, local.y), EDGE_TOLERANCE / this.zoom); + if (!id) return false; + if (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey)) { + this.state.toggleEdge(this.assetId, id); + return true; + } + // Pressing a link of a wider selection keeps that selection, so its menu speaks about all of it. + if (!this.state.selectedEdgeIds.includes(id)) this.state.select(edgeSelection(this.assetId, [id])); + if (e.button === 2) openEdgeMenu(this.state, this.assetId, e.global.x, e.global.y); + return true; + } + private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { // Panning takes precedence over the node: let the middle button bubble up to the canvas. if (e.button === 1) return; @@ -675,7 +705,12 @@ export class GraphCanvas extends Widget { const graph = this.asset()?.graph; const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), band) : []; // An empty band clears the selection but keeps the behavior, as a plain click does. - this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId }); + this.state.select( + graphSelection(this.assetId, ids, boxIds, this.edgeLayer.edgesIn(band)) ?? { + kind: 'asset', + assetId: this.assetId, + }, + ); } private nodeUnder(globalX: number, globalY: number): string | null { @@ -829,6 +864,7 @@ export class GraphCanvas extends Widget { (id) => isHot(trace?.edges, id), selectedNodes, (id) => this.beads.inFlight(id, now), + new Set(this.state.selectedEdgeIds), ); this.wiring.draw(this.tempEdge, asset.graph); @@ -910,6 +946,17 @@ export class GraphCanvas extends Widget { }; } + /** Halfway along a link as drawn, on screen – where a check clicks it. Null for one without a line. */ + edgeGlobal(edgeId: string): { x: number; y: number } | null { + const middle = this.edgeLayer.edgeMiddle(edgeId); + return middle ? globalPoint(this.edgeLayer, middle) : null; + } + + /** The links the last redraw marked as picked. */ + pickedEdgesDrawn(): string[] { + return this.edgeLayer.pickedDrawn(); + } + /** 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; diff --git a/src/editor/graph/edgeHit.ts b/src/editor/graph/edgeHit.ts new file mode 100644 index 00000000..38300778 --- /dev/null +++ b/src/editor/graph/edgeHit.ts @@ -0,0 +1,103 @@ +/** Which link a click or a rubber band means, kept out of the canvas so tests can reach it (#289). */ + +import type { Band } from '../framing'; +import { curvePoint } from './beads'; +import { type Loop, loopPoint, type Point, type Rect } from './transitions'; + +/** One link as the last draw laid it out, in graph units. */ +export type EdgePath = { edgeId: string } & ( + | { kind: 'curve'; start: Point; end: Point } + | { kind: 'line'; start: Point; end: Point } + | { kind: 'loop'; loop: Loop } +); + +/** A transition's name plate: clicking the name means its arrow. */ +export type EdgePlate = Rect & { edgeId: string }; + +/** Straight pieces a curve is tested as; at 24 the error stays far below a pixel of tolerance. */ +const CURVE_STEPS = 24; + +export function pathPoints(path: EdgePath): Point[] { + if (path.kind === 'line') return [path.start, path.end]; + const at = (t: number) => + path.kind === 'loop' + ? loopPoint(path.loop, t) + : curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, t); + return Array.from({ length: CURVE_STEPS + 1 }, (_, i) => at(i / CURVE_STEPS)); +} + +/** The point halfway along a link – where a check clicks it. */ +export function pathMiddle(path: EdgePath): Point { + if (path.kind === 'line') return { x: (path.start.x + path.end.x) / 2, y: (path.start.y + path.end.y) / 2 }; + return path.kind === 'loop' + ? loopPoint(path.loop, 0.5) + : curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, 0.5); +} + +function distanceToSegment(p: Point, a: Point, b: Point): number { + const [dx, dy] = [b.x - a.x, b.y - a.y]; + const length = dx * dx + dy * dy; + const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)); + return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)); +} + +export function distanceToPath(path: EdgePath, point: Point): number { + const points = pathPoints(path); + let nearest = Infinity; + for (let i = 1; i < points.length; i++) + nearest = Math.min(nearest, distanceToSegment(point, points[i - 1]!, points[i]!)); + return nearest; +} + +/** + * The link under a point, or null. A name plate wins over any line, and of several lines within + * `tolerance` the one drawn last does – it is the one on top. + */ +export function edgeAt( + paths: readonly EdgePath[], + point: Point, + tolerance: number, + plates: readonly EdgePlate[] = [], +): string | null { + for (let i = plates.length - 1; i >= 0; i--) { + const p = plates[i]!; + if (point.x >= p.x && point.x <= p.x + p.w && point.y >= p.y && point.y <= p.y + p.h) return p.edgeId; + } + for (let i = paths.length - 1; i >= 0; i--) + if (distanceToPath(paths[i]!, point) <= tolerance) return paths[i]!.edgeId; + return null; +} + +/** Whether the segment a-b has any point inside the rectangle (Liang-Barsky). */ +function segmentInRect(a: Point, b: Point, minX: number, minY: number, maxX: number, maxY: number): boolean { + let [t0, t1] = [0, 1]; + const [dx, dy] = [b.x - a.x, b.y - a.y]; + for (const [p, q] of [ + [-dx, a.x - minX], + [dx, maxX - a.x], + [-dy, a.y - minY], + [dy, maxY - a.y], + ] as const) { + if (p === 0) { + if (q < 0) return false; + continue; + } + const r = q / p; + if (p < 0) t0 = Math.max(t0, r); + else t1 = Math.min(t1, r); + if (t0 > t1) return false; + } + return true; +} + +/** Ids of every link the band touches, in drawing order: a line is thin, so crossing it counts. */ +export function edgesInBand(paths: readonly EdgePath[], band: Band): string[] { + const [minX, maxX] = [Math.min(band.x1, band.x2), Math.max(band.x1, band.x2)]; + const [minY, maxY] = [Math.min(band.y1, band.y2), Math.max(band.y1, band.y2)]; + return paths + .filter((path) => { + const points = pathPoints(path); + return points.some((p, i) => i > 0 && segmentInRect(points[i - 1]!, p, minX, minY, maxX, maxY)); + }) + .map((path) => path.edgeId); +} diff --git a/src/editor/graph/graphMenus.ts b/src/editor/graph/graphMenus.ts index 026b5cbe..f8859986 100644 --- a/src/editor/graph/graphMenus.ts +++ b/src/editor/graph/graphMenus.ts @@ -4,7 +4,7 @@ import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../ import { annotationNoun } from '../../core/commands/annotationCommands'; import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph'; import { Menu, type MenuItem } from '../../ui'; -import { removeGraphSelection, removeNodes } from '../actions'; +import { hideLinks, linkNoun, removeGraphSelection, removeNodes } from '../actions'; import { copySelection } from '../clipboard'; import type { EditorState } from '../state'; @@ -100,3 +100,36 @@ export function openLinkMenu( ...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true), ]).open(x, y); } + +/** + * For the selected links (#289), opened on one of them. Like a box's menu it speaks about the whole + * selection, so deleting from it takes the nodes and boxes held alongside too. + */ +export function openEdgeMenu(state: EditorState, assetId: string, x: number, y: number): void { + const edges = state.selectedEdgeIds; + const others = state.selectedNodeIds.length + state.selectedAnnotationIds.length; + const noun = linkNoun(state, assetId); + const hide: MenuItem[] = + noun === 'link' + ? [ + { + label: edges.length === 1 ? 'Hide link' : `Hide ${edges.length} links`, + onSelect: () => hideLinks(state, assetId, edges), + }, + ] + : []; + new Menu([ + ...hide, + { + label: + others > 0 + ? `Delete ${edges.length + others} items` + : edges.length === 1 + ? `Delete ${noun}` + : `Delete ${edges.length} ${noun}s`, + danger: true, + onSelect: () => + removeGraphSelection(state, assetId, state.selectedNodeIds, state.selectedAnnotationIds, edges), + }, + ]).open(x, y); +} diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index a1118b82..f651f199 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -5,6 +5,7 @@ import { activateAsset } from '../actions'; import { type EditorState, sameSelection, type Selection } from '../state'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection'; +import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection'; import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection'; import type { InspectorHost } from './host'; import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection'; @@ -123,6 +124,7 @@ export class Inspector extends Panel { else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds); else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId); else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); + else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); else buildObjectSection(this.host, s.holderId, s.objectId); this.scroll.setContent(this.host.body); @@ -137,6 +139,7 @@ export class Inspector extends Panel { if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds); if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); + if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds); return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); } @@ -168,7 +171,9 @@ export class Inspector extends Panel { ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) : s.kind === 'track' ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) - : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); + : s.kind === 'edge' + ? refreshEdgeSection(this.host, s.assetId, s.edgeId) + : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } } diff --git a/src/editor/inspector/edgeSection.ts b/src/editor/inspector/edgeSection.ts new file mode 100644 index 00000000..0522545b --- /dev/null +++ b/src/editor/inspector/edgeSection.ts @@ -0,0 +1,124 @@ +import { NameTransitionCommand } from '../../core/commands/graphCommands'; +import { findNode, type GraphEdge, isStateNode, resolveDef, stateName } from '../../core/graph'; +import { graphLookup } from '../../core/model/graphLookup'; +import { findGraphAsset, type Project } from '../../core/model/Project'; +import { textOf } from '../../core/model/Properties'; +import { Button, Label } from '../../ui'; +import { hideLinks, linkNoun, removeGraphSelection } from '../actions'; +import { createFieldRow, labelledRow } from './fieldFactory'; +import { addGap, addNote, addTitle, type InspectorHost } from './host'; + +const NAME_KEY = 'transition-name'; + +/** What the panel says about one link: the two ends by the names drawn on them. */ +export interface EdgeDescription { + transition: boolean; + from: string; + output: string; + to: string; + input: string; + /** What flows through it, as the output names it. */ + type: string; + name: string; +} + +/** Null when the link is gone. An end whose node or port is gone reads `?`. */ +export function describeEdge(project: Project, assetId: string, edgeId: string): EdgeDescription | null { + const graph = findGraphAsset(project, assetId)?.graph; + const edge = graph?.edges.find((e) => e.id === edgeId); + if (!graph || !edge) return null; + const lookup = graphLookup(project); + const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => { + const node = findNode(graph, side.node); + const def = node && resolveDef(node, graph, lookup); + const port = def?.[ports].find((p) => p.key === side.port); + return { + state: node !== undefined && isStateNode(node), + node: node ? (isStateNode(node) ? stateName(node) : (def?.label ?? node.type)) : '?', + port: port ? port.label || port.key : '?', + type: port?.type ?? '?', + }; + }; + const from = end(edge.from, 'outputs'); + const to = end(edge.to, 'inputs'); + return { + transition: from.state && to.state, + from: from.node, + output: from.port, + to: to.node, + input: to.port, + type: from.type, + name: edge.label ?? '', + }; +} + +/** Structure of a link selection: how many, and for one of them what it joins. */ +export function edgeSignature( + project: Project, + assetId: string, + edgeId: string, + edgeIds: readonly string[], +): string { + if (edgeIds.length > 1) return `many:${edgeIds.length}`; + const d = describeEdge(project, assetId, edgeId); + return d ? [d.transition, d.from, d.output, d.to, d.input, d.type].join('|') : 'missing'; +} + +/** One link: where it comes from and goes to. Several: how many, and the way to delete them. */ +export function buildEdgeSection( + host: InspectorHost, + assetId: string, + edgeId: string, + edgeIds: readonly string[], +): void { + const noun = linkNoun(host.state, assetId); + const remove = (label: string) => + host.body.add( + new Button(label, { + variant: 'danger', + onClick: () => removeGraphSelection(host.state, assetId, [], [], edgeIds), + }), + ); + if (edgeIds.length > 1) { + addTitle(host, `${edgeIds.length} ${noun.toUpperCase()}S`); + addNote(host, 'Delete removes all of them in one step.'); + addGap(host); + remove(`Delete ${edgeIds.length} ${noun}s`); + return; + } + const d = describeEdge(host.state.project, assetId, edgeId); + if (!d) return; + addTitle(host, noun.toUpperCase()); + const fact = (label: string, value: string) => { + const { row } = labelledRow(label); + row.add(new Label(value), 1); + host.body.add(row); + }; + fact('From', d.from); + if (!d.transition) fact('Output', d.output); + fact('To', d.to); + if (d.transition) { + const name = createFieldRow(host, { key: NAME_KEY, label: 'Name', kind: 'text', default: '' }, d.name, { + onInput: () => {}, + onChange: (next) => void host.state.execute(new NameTransitionCommand(assetId, edgeId, textOf(next))), + }); + host.fields.set(NAME_KEY, name); + host.body.add(name.row); + addNote(host, 'A State transition node takes it by this name.', 'small'); + } else { + fact('Input', d.input); + fact('Type', d.type); + } + addGap(host); + if (!d.transition) + host.body.add(new Button('Hide link', { onClick: () => hideLinks(host.state, assetId, [edgeId]) })); + remove(`Delete ${noun}`); +} + +/** Pushes the current name into its row. False when the link is gone (rebuild). */ +export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId: string): boolean { + const d = describeEdge(host.state.project, assetId, edgeId); + if (!d) return false; + host.fields.get(NAME_KEY)?.setValue(d.name); + return true; +} diff --git a/src/editor/inspector/transitionSection.ts b/src/editor/inspector/transitionSection.ts index 7cba39eb..ce7868ab 100644 --- a/src/editor/inspector/transitionSection.ts +++ b/src/editor/inspector/transitionSection.ts @@ -58,8 +58,8 @@ export function buildTransitionPicker( /** 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. */ +/** 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, diff --git a/src/editor/state.ts b/src/editor/state.ts index 86720104..b1dcf2b7 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -5,7 +5,7 @@ import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/ import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; import type { Asset, SceneAsset } from '../core/model/Asset'; -import { findAnnotation } from '../core/graph'; +import { findAnnotation, type Graph } from '../core/graph'; import { findObject } from '../core/model/hierarchy'; import { resolvedHolder } from '../core/model/instance'; import { @@ -37,9 +37,17 @@ export type Selection = /** * One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). * `annotationIds` holds the groups and comments caught alongside them: a rubber band spans - * whatever lies under it, and the two sorts move and die together from then on. + * whatever lies under it, and the two sorts move and die together from then on. `edgeIds` holds + * the links picked alongside (#289); they do not move, but Delete takes them too. */ - | { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] } + | { + kind: 'node'; + assetId: string; + nodeId: string; + nodeIds: string[]; + annotationIds: string[]; + edgeIds: string[]; + } /** A frame rectangle inside a texture asset (the Texture tab). */ | { kind: 'frame'; assetId: string; frameId: string } /** @@ -48,7 +56,9 @@ export type Selection = */ | { kind: 'shape'; assetId: string; shapeId: string; shapeIds: string[] } /** One or more groups or comments of a graph, `annotationId` being the primary one. */ - | { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] } + | { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[]; edgeIds: string[] } + /** One or more links of a graph and nothing else, `edgeId` being the primary one (#289). */ + | { kind: 'edge'; assetId: string; edgeId: string; edgeIds: string[] } /** * A row of an animation's timeline (the Animation tab), and the keys picked on it or on other * rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector @@ -88,6 +98,7 @@ export function nodeSelection( nodeIds: readonly string[], primary?: string, annotationIds: readonly string[] = [], + edgeIds: readonly string[] = [], ): Selection { const picked = pickIds(nodeIds, primary); return ( @@ -97,6 +108,7 @@ export function nodeSelection( nodeId: picked.primary, nodeIds: picked.ids, annotationIds: [...new Set(annotationIds)], + edgeIds: [...new Set(edgeIds)], } ); } @@ -112,22 +124,42 @@ export function annotationSelection( assetId: string, annotationIds: readonly string[], primary?: string, + edgeIds: readonly string[] = [], ): Selection { const picked = pickIds(annotationIds, primary); - return picked && { kind: 'annotation', assetId, annotationId: picked.primary, annotationIds: picked.ids }; + return ( + picked && { + kind: 'annotation', + assetId, + annotationId: picked.primary, + annotationIds: picked.ids, + edgeIds: [...new Set(edgeIds)], + } + ); +} + +/** Builds a selection of links alone; the last id is the primary one unless given explicitly. */ +export function edgeSelection(assetId: string, edgeIds: readonly string[], primary?: string): Selection { + const picked = pickIds(edgeIds, primary); + return picked && { kind: 'edge', assetId, edgeId: picked.primary, edgeIds: picked.ids }; } /** * What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps - * showing a node where it did before, and a band over boxes alone still selects them. + * showing a node where it did before, then boxes, and a band over links alone still selects them. + * `primary` names the one picked last, in whichever of the three lists it is. */ export function graphSelection( assetId: string, nodeIds: readonly string[], annotationIds: readonly string[], + edgeIds: readonly string[] = [], + primary?: string, ): Selection { return ( - nodeSelection(assetId, nodeIds, undefined, annotationIds) ?? annotationSelection(assetId, annotationIds) + nodeSelection(assetId, nodeIds, primary, annotationIds, edgeIds) ?? + annotationSelection(assetId, annotationIds, primary, edgeIds) ?? + edgeSelection(assetId, edgeIds, primary) ); } @@ -471,6 +503,8 @@ export class EditorState { selection.nodeId, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection.annotationIds ?? [], + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + selection.edgeIds ?? [], ); if (selection?.kind === 'annotation') selection = annotationSelection( @@ -478,7 +512,12 @@ export class EditorState { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition selection.annotationIds ?? [selection.annotationId], selection.annotationId, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + selection.edgeIds ?? [], ); + if (selection?.kind === 'edge') + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + selection = edgeSelection(selection.assetId, selection.edgeIds ?? [selection.edgeId], selection.edgeId); // The short form `{ trackId }` names a row without keys. if (selection?.kind === 'track') // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -549,15 +588,42 @@ export class EditorState { const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : []; const held = current.includes(nodeId); const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId]; - // Boxes caught alongside stay held: the toggle speaks about this node, not about them. - const boxes = s?.kind === 'node' && s.assetId === assetId ? s.annotationIds : []; + // Boxes and links caught alongside stay held: the toggle speaks about this node, not about them. + const mine = s?.kind === 'node' && s.assetId === assetId; + const boxes = mine ? s.annotationIds : []; + const edges = this.graphHolds(assetId) ? this.selectedEdgeIds : []; // Emptied by the toggle, the behavior itself stays selected – the graph must not close. this.select( - nodeSelection(assetId, next, held ? undefined : nodeId, boxes) ?? - annotationSelection(assetId, boxes) ?? { kind: 'asset', assetId }, + graphSelection(assetId, next, boxes, edges, held ? undefined : nodeId) ?? { kind: 'asset', assetId }, ); } + /** Ctrl+click on a link (#289): in or out of the selection, whatever else of the graph is held. */ + toggleEdge(assetId: string, edgeId: string): void { + const mine = this.graphHolds(assetId); + const current = mine ? this.selectedEdgeIds : []; + const held = current.includes(edgeId); + const next = held ? current.filter((id) => id !== edgeId) : [...current, edgeId]; + const s = this.selection; + // A node or box already shown in the inspector stays the primary one. + const primary = s?.kind === 'node' ? s.nodeId : s?.kind === 'annotation' ? s.annotationId : undefined; + this.select( + graphSelection( + assetId, + mine ? this.selectedNodeIds : [], + mine ? this.selectedAnnotationIds : [], + next, + mine ? (primary ?? (held ? undefined : edgeId)) : edgeId, + ) ?? { kind: 'asset', assetId }, + ); + } + + /** Whether the selection is a node, box or link of this graph. */ + private graphHolds(assetId: string): boolean { + const s = this.selection; + return (s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge') && s.assetId === assetId; + } + get selectedObjectIds(): string[] { return this.selection?.kind === 'object' ? this.selection.objectIds : []; } @@ -573,6 +639,12 @@ export class EditorState { return s?.kind === 'annotation' ? s.annotationIds : []; } + /** The links selected, whether alongside nodes and boxes or on their own. */ + get selectedEdgeIds(): string[] { + const s = this.selection; + return s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge' ? s.edgeIds : []; + } + /** Drops selected targets that no longer exist (after delete/undo). */ private validateSelection(): void { const s = this.selection; @@ -591,22 +663,47 @@ export class EditorState { else { const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id)); const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id)); - if (alive.length !== s.nodeIds.length || boxes.length !== s.annotationIds.length) + const edges = liveEdges(asset.graph, s.edgeIds); + if ( + alive.length !== s.nodeIds.length || + boxes.length !== s.annotationIds.length || + edges.length !== s.edgeIds.length + ) this.select( - nodeSelection(s.assetId, alive, s.nodeId, boxes) ?? - annotationSelection(s.assetId, boxes) ?? { kind: 'asset', assetId: s.assetId }, + graphSelection( + s.assetId, + alive, + boxes, + edges, + alive.includes(s.nodeId) ? s.nodeId : undefined, + ) ?? { + kind: 'asset', + assetId: s.assetId, + }, ); } } + if (s.kind === 'edge') { + const asset = findGraphAsset(this.project, s.assetId); + // Same fallback as a node: the graph stays open when the link it held vanished (undo). + if (!asset) this.select(null); + else { + const alive = liveEdges(asset.graph, s.edgeIds); + if (alive.length !== s.edgeIds.length) + this.select(edgeSelection(s.assetId, alive, s.edgeId) ?? { kind: 'asset', assetId: s.assetId }); + } + } if (s.kind === 'annotation') { const asset = findGraphAsset(this.project, s.assetId); // Same fallback as a node: the graph stays open when the box it held vanished (undo). if (!asset) this.select(null); else { const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id)); - if (alive.length !== s.annotationIds.length) + const edges = liveEdges(asset.graph, s.edgeIds); + if (alive.length !== s.annotationIds.length || edges.length !== s.edgeIds.length) this.select( - annotationSelection(s.assetId, alive, s.annotationId) ?? { kind: 'asset', assetId: s.assetId }, + annotationSelection(s.assetId, alive, s.annotationId, edges) ?? + edgeSelection(s.assetId, edges) ?? { kind: 'asset', assetId: s.assetId }, ); } } @@ -715,10 +812,12 @@ export class EditorState { const s = this.selection; if (!s) return null; if (s.kind === 'frame') return null; - // A node names its asset; which tab that is depends on what the asset is. A group or comment - // names it the same way, so selecting one keeps its graph on screen. + // A node names its asset; which tab that is depends on what the asset is. A group, a comment + // and a link name it the same way, so selecting one keeps its graph on screen. const asset = - s.kind === 'node' || s.kind === 'annotation' ? findAsset(this.project, s.assetId) : this.assetBehind(s); + s.kind === 'node' || s.kind === 'annotation' || s.kind === 'edge' + ? findAsset(this.project, s.assetId) + : this.assetBehind(s); return asset && asset.type === type ? asset.id : null; } @@ -785,7 +884,8 @@ export function sameSelection(a: Selection, b: Selection): boolean { a.assetId === b.assetId && a.nodeId === b.nodeId && sameIds(a.nodeIds, b.nodeIds) && - sameIds(a.annotationIds, b.annotationIds) + sameIds(a.annotationIds, b.annotationIds) && + sameIds(a.edgeIds, b.edgeIds) ); } if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId; @@ -802,8 +902,11 @@ export function sameSelection(a: Selection, b: Selection): boolean { return ( a.assetId === b.assetId && a.annotationId === b.annotationId && - sameIds(a.annotationIds, b.annotationIds) + sameIds(a.annotationIds, b.annotationIds) && + sameIds(a.edgeIds, b.edgeIds) ); + if (a.kind === 'edge' && b.kind === 'edge') + return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds); return false; } @@ -811,3 +914,8 @@ export function sameSelection(a: Selection, b: Selection): boolean { function sameIds(a: readonly string[], b: readonly string[]): boolean { return a.length === b.length && a.every((id, i) => id === b[i]); } + +/** The ids that still name a link of the graph. */ +function liveEdges(graph: Graph, ids: readonly string[]): string[] { + return ids.filter((id) => graph.edges.some((e) => e.id === id)); +} diff --git a/tests/editor/edge-selection.test.ts b/tests/editor/edge-selection.test.ts new file mode 100644 index 00000000..cbcc91f7 --- /dev/null +++ b/tests/editor/edge-selection.test.ts @@ -0,0 +1,235 @@ +/** + * A link is picked on its line (#289): a click near the curve means that link, Ctrl adds another, + * a rubber band takes every line it crosses, and Delete removes what is picked in one undo step. + * A hidden link has no line, so it is never among the paths these rules are asked about. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands'; +import { type Graph, STATE, STATE_IN, STATE_NAME_PARAM, STATE_OUT } from '../../src/core/graph'; +import { createEmptyProject, findGraphAsset } from '../../src/core/model/Project'; +import { hideLinks, removeGraphSelection, removeSelection } from '../../src/editor/actions'; +import { + distanceToPath, + edgeAt, + type EdgePath, + edgesInBand, + pathMiddle, +} from '../../src/editor/graph/edgeHit'; +import { selfLoop } from '../../src/editor/graph/transitions'; +import { describeEdge, edgeSignature } from '../../src/editor/inspector/edgeSection'; +import { edgeSelection, EditorState, graphSelection } from '../../src/editor/state'; + +const curve = (edgeId: string, y1: number, y2: number): EdgePath => ({ + edgeId, + kind: 'curve', + start: { x: 0, y: y1 }, + end: { x: 200, y: y2 }, +}); + +describe('which link a point means', () => { + it('takes the link whose curve runs within the tolerance, and none further off', () => { + const paths = [curve('a', 0, 100)]; + const on = pathMiddle(paths[0]!); + expect(on).toEqual({ x: 100, y: 50 }); + expect(edgeAt(paths, { x: on.x + 4, y: on.y }, 6)).toBe('a'); + expect(edgeAt(paths, { x: on.x + 40, y: on.y }, 6)).toBeNull(); + // The straight line between the ports is not the curve: its quarter point is well off it. + expect(distanceToPath(paths[0]!, { x: 50, y: 25 })).toBeGreaterThan(6); + }); + + it('takes the one drawn last where two cross', () => { + const paths = [curve('under', 0, 100), curve('over', 100, 0)]; + expect(edgeAt(paths, { x: 100, y: 50 }, 6)).toBe('over'); + expect(edgeAt([...paths].reverse(), { x: 100, y: 50 }, 6)).toBe('under'); + }); + + it('follows a straight transition and a loop back into its own state', () => { + const line: EdgePath = { edgeId: 'line', kind: 'line', start: { x: 0, y: 0 }, end: { x: 100, y: 100 } }; + const loop: EdgePath = { + edgeId: 'loop', + kind: 'loop', + loop: selfLoop({ x: 300, y: 300, w: 100, h: 40 }), + }; + expect(edgeAt([line, loop], { x: 52, y: 48 }, 6)).toBe('line'); + expect(edgeAt([line, loop], { x: 80, y: 20 }, 6)).toBeNull(); + expect(edgeAt([line, loop], pathMiddle(loop), 6)).toBe('loop'); + // Inside the loop is not on it. + expect(edgeAt([line, loop], { x: 350, y: 285 }, 6)).toBeNull(); + }); + + it('means the transition when its name plate is pressed, before any line under it', () => { + const paths = [curve('line', 0, 0)]; + const plates = [{ edgeId: 'named', x: 80, y: -8, w: 40, h: 16 }]; + expect(edgeAt(paths, { x: 100, y: 0 }, 6, plates)).toBe('named'); + expect(edgeAt(paths, { x: 20, y: 0 }, 6, plates)).toBe('line'); + }); + + it('gives a band every link it crosses, not only those with an end inside', () => { + const paths = [curve('a', 0, 100), curve('far', 400, 400)]; + expect(edgesInBand(paths, { x1: 90, y1: 20, x2: 110, y2: 80 })).toEqual(['a']); + // Drawn the other way round it is the same rectangle. + expect(edgesInBand(paths, { x1: 110, y1: 80, x2: 90, y2: 20 })).toEqual(['a']); + expect(edgesInBand(paths, { x1: 90, y1: 150, x2: 110, y2: 200 })).toEqual([]); + expect(edgesInBand(paths, { x1: -10, y1: -10, x2: 300, y2: 500 })).toEqual(['a', 'far']); + }); +}); + +function fixture() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('behavior', 'B'); + expect(state.execute(create)).toBe(true); + const behaviorId = create.asset.id; + const graphOf = (): Graph => findGraphAsset(state.project, behaviorId)!.graph; + const addNode = (type: string): string => { + const command = new AddNodeCommand(behaviorId, type, 0, 0); + expect(state.execute(command)).toBe(true); + return command.node.id; + }; + const connect = (from: string, to: string, port: string): string => { + const command = new ConnectCommand(behaviorId, { node: from, port: 'value' }, { node: to, port }); + expect(state.execute(command)).toBe(true); + return command.edge.id; + }; + const [one, two, add] = [addNode('Number'), addNode('Number'), addNode('Add')]; + const [first, second] = [connect(one, add, 'a'), connect(two, add, 'b')]; + const edgeIds = () => graphOf().edges.map((e) => e.id); + return { state, behaviorId, graphOf, one, two, add, first, second, edgeIds }; +} + +describe('a selection of links', () => { + let f: ReturnType; + beforeEach(() => { + f = fixture(); + }); + + it('is its own kind, and keeps the graph of its asset on screen', () => { + f.state.select(edgeSelection(f.behaviorId, [f.first])); + expect(f.state.selection).toEqual({ + kind: 'edge', + assetId: f.behaviorId, + edgeId: f.first, + edgeIds: [f.first], + }); + expect(f.state.selectedEdgeIds).toEqual([f.first]); + expect(f.state.selectedBehaviorId).toBe(f.behaviorId); + }); + + it('adds and takes a link with the toggle, and falls back to the asset when emptied', () => { + f.state.select(edgeSelection(f.behaviorId, [f.first])); + f.state.toggleEdge(f.behaviorId, f.second); + expect(f.state.selection).toMatchObject({ kind: 'edge', edgeId: f.second, edgeIds: [f.first, f.second] }); + f.state.toggleEdge(f.behaviorId, f.second); + f.state.toggleEdge(f.behaviorId, f.first); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId }); + }); + + it('rides along with nodes: the node stays what the inspector shows, either toggle keeps the rest', () => { + f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first])); + expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first] }); + f.state.toggleEdge(f.behaviorId, f.second); + expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first, f.second] }); + f.state.toggleNode(f.behaviorId, f.two); + expect(f.state.selectedNodeIds).toEqual([f.one, f.two]); + expect(f.state.selectedEdgeIds).toEqual([f.first, f.second]); + // The last node toggled away leaves the links, as a selection of their own. + f.state.toggleNode(f.behaviorId, f.one); + f.state.toggleNode(f.behaviorId, f.two); + expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first, f.second] }); + }); + + it('deletes exactly the picked link on Delete, and one undo brings it back', () => { + f.state.select(edgeSelection(f.behaviorId, [f.first])); + expect(removeSelection(f.state)).toBe(true); + expect(f.edgeIds()).toEqual([f.second]); + // The link is gone, so the selection falls back to the graph it was in. + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId }); + f.state.undo(); + expect(f.edgeIds()).toEqual([f.first, f.second]); + }); + + it('deletes several links, and a node with them, as one undo step', () => { + f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first, f.second])); + expect(removeSelection(f.state)).toBe(true); + expect(f.edgeIds()).toEqual([]); + expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.two, f.add]); + f.state.undo(); + expect(f.edgeIds()).toEqual([f.first, f.second]); + expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.one, f.two, f.add]); + f.state.redo(); + expect(f.edgeIds()).toEqual([]); + }); + + it('has nothing to delete when the link is already gone', () => { + expect(removeGraphSelection(f.state, f.behaviorId, [], [], ['gone'])).toBe(false); + expect(f.edgeIds()).toEqual([f.first, f.second]); + }); + + it('lets go of a link that is hidden, since it has no line left to be picked on', () => { + f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first])); + expect(hideLinks(f.state, f.behaviorId, [f.first])).toBe(true); + expect(f.graphOf().edges[0]!.folded).toBe(true); + expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [] }); + }); + + it('drops a link from the selection when an undo takes it away', () => { + f.state.select(edgeSelection(f.behaviorId, [f.first, f.second])); + f.state.undo(); + expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first] }); + }); + + it('describes a link by the names drawn at its two ends', () => { + expect(describeEdge(f.state.project, f.behaviorId, f.second)).toEqual({ + transition: false, + from: 'Number', + output: 'value', + to: 'Add', + input: 'b', + type: 'number', + name: '', + }); + expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull(); + expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2'); + expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first])).not.toBe( + edgeSignature(f.state.project, f.behaviorId, f.second, [f.second]), + ); + }); +}); + +describe('a picked transition', () => { + function machine() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('statemachine', 'Guard'); + expect(state.execute(create)).toBe(true); + const id = create.asset.id; + const [patrol, chase] = ['Patrol', 'Chase'].map((name) => { + const add = new AddNodeCommand(id, STATE, 0, 0, { [STATE_NAME_PARAM]: name }); + expect(state.execute(add)).toBe(true); + return add.node.id; + }) as [string, string]; + const connect = new ConnectCommand( + id, + { node: patrol, port: STATE_OUT }, + { node: chase, port: STATE_IN }, + ); + expect(state.execute(connect)).toBe(true); + return { state, id, edge: connect.edge.id }; + } + + it('is described by its two states and its name, and goes as a transition', () => { + const m = machine(); + expect(m.state.execute(new NameTransitionCommand(m.id, m.edge, 'alarm'))).toBe(true); + expect(describeEdge(m.state.project, m.id, m.edge)).toMatchObject({ + transition: true, + from: 'Patrol', + to: 'Chase', + name: 'alarm', + }); + m.state.select(edgeSelection(m.id, [m.edge])); + expect(m.state.selectedStateMachineId).toBe(m.id); + expect(removeSelection(m.state)).toBe(true); + expect(findGraphAsset(m.state.project, m.id)!.graph.edges).toEqual([]); + }); +}); diff --git a/tests/editor/shader-preview.test.ts b/tests/editor/shader-preview.test.ts index 37d5eb8a..9b21c505 100644 --- a/tests/editor/shader-preview.test.ts +++ b/tests/editor/shader-preview.test.ts @@ -71,6 +71,7 @@ describe('the picture the Shader tab previews (#168)', () => { nodeId: 'n', nodeIds: ['n'], annotationIds: [], + edgeIds: [], }; expect(pictureSubjectOf(f.project, node)).toBeNull(); }); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 5810dadb..68915de1 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1108,6 +1108,7 @@ export async function graph(t) { await eventOrder(); await animationPlaying(); await layerOf(); + await pickedLinks(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1929,4 +1930,99 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => !o.id.startsWith('lo-')); `); } + + // 6z-xi. Links are picked on their line (#289): a click picks one and the inspector shows it, + // Ctrl+click adds another, a rubber band takes every line it crosses, and Delete or the + // line's own menu removes what is picked. The port keeps its Disconnect. + async function pickedLinks() { + await mutate(` + const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params }); + const edge = (id, from, to, toPort) => + ({ id, from: { node: from, port: 'value' }, to: { node: to, port: toPort } }); + __gm.state.project.assets.push({ + id: 'lky', + type: 'behavior', + name: 'Link picker', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { + nodes: [ + node('lk-one', 'Number', -300, -160, { value: 1 }), + node('lk-two', 'Number', -300, 160, { value: 2 }), + node('lk-add', 'Add', 200, 0), + ], + edges: [edge('lk-e1', 'lk-one', 'lk-add', 'a'), edge('lk-e2', 'lk-two', 'lk-add', 'b')], + }, + }); + __gm.state.select({ kind: 'asset', assetId: 'lky' }); + `); + await showTab('graph'); + await clickText('Fit'); + const edges = async () => (await asset('lky')).graph.edges.map((e) => e.id); + const drawn = () => json('__gm.graph.pickedEdges()'); + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + const at = async (id) => json(`__gm.graph.edgeAt(${JSON.stringify(id)})`); + const [one, two] = [await at('lk-e1'), await at('lk-e2')]; + + await click(one.x, one.y); + let s = await state(); + await shot('30-link-picked'); + check( + 'clicking a link picks it: it is drawn picked and the inspector shows where it runs', + s.selection?.kind === 'edge' && + s.selection.edgeId === 'lk-e1' && + same(await drawn(), ['lk-e1']) && + Boolean(await find('LINK')) && + Boolean(await find('Delete link')), + JSON.stringify({ selection: s.selection, drawn: await drawn() }), + ); + await press('Delete'); + const afterDelete = await edges(); + await undo(); + check( + 'Delete removes exactly the picked link, and undo brings it back', + same(afterDelete, ['lk-e2']) && same(await edges(), ['lk-e1', 'lk-e2']), + JSON.stringify({ afterDelete, undone: await edges() }), + ); + + await click(one.x, one.y); + await click(two.x, two.y, 'left', { ctrl: true }); + const both = await drawn(); + // Off both lines and every node: a click on bare canvas lets go of them again. + await click(one.x, two.y + 60); + const none = await drawn(); + await drag(one.x - 12, one.y - 30, two.x + 12, two.y + 30); + s = await state(); + check( + 'Ctrl+click adds a second link, and a rubber band across the lines takes both without a node', + same(both, ['lk-e1', 'lk-e2']) && + same(none, []) && + s.selection?.kind === 'edge' && + same(s.selection.edgeIds, ['lk-e1', 'lk-e2']) && + Boolean(await find('2 LINKS')), + JSON.stringify({ both, none, selection: s.selection }), + ); + + await click(one.x, two.y + 60); + await click(two.x, two.y, 'right'); + const offered = Boolean(await find('Hide link')); + // The inspector shows a button of the same name; the menu's entry is the one drawn last. + const entry = await locate('Delete link', true); + await click(entry.x, entry.y); + const afterMenu = await edges(); + await undo(); + const port = await json(`__gm.graph.portAt('lk-add', 'a', false)`); + await click(port.x, port.y, 'right'); + const atPort = Boolean(await find('Disconnect')); + await press('Escape'); + check( + 'right-clicking a link offers to hide or delete it, and the port keeps its Disconnect', + offered && same(afterMenu, ['lk-e1']) && same(await edges(), ['lk-e1', 'lk-e2']) && atPort, + JSON.stringify({ offered, afterMenu, atPort }), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'lky'); + `); + } } diff --git a/tools/e2e/26-state-machine.mjs b/tools/e2e/26-state-machine.mjs index 971e7f39..02283db2 100644 --- a/tools/e2e/26-state-machine.mjs +++ b/tools/e2e/26-state-machine.mjs @@ -12,6 +12,7 @@ export async function stateMachine(t) { check, sleep, json, + state, evaluate, shot, mutate, @@ -364,6 +365,34 @@ export async function stateMachine(t) { (await transitions()).filter((e) => e.from.node === first && e.to.node === second).length === 1, ); + // 26e7. (#289) The arrow itself is a handle: a click picks the transition and the inspector shows + // it, Delete takes exactly that one, and an undo brings it back. + const alarm = (await transitions()).find((e) => e.from.node === first && e.to.node === second); + const arrow = await json(`__gm.stateMachine.edgeAt(${JSON.stringify(alarm?.id)})`); + const beforePick = (await transitions()).length; + await click(arrow.x, arrow.y); + const arrowSelection = (await state()).selection; + await shot('05f-picked'); + check( + 'clicking a transition picks it: the inspector shows it with its name and the way to delete it', + arrowSelection?.kind === 'edge' && + arrowSelection.edgeId === alarm?.id && + JSON.stringify(await json('__gm.stateMachine.pickedEdges()')) === JSON.stringify([alarm?.id]) && + (await has('TRANSITION')) && + (await has('Delete transition')), + JSON.stringify({ arrowSelection, drawn: await json('__gm.stateMachine.pickedEdges()') }), + ); + await press('Delete'); + const afterDelete = await transitions(); + await undo(); + check( + 'Delete takes the picked transition alone, and undo brings it back', + afterDelete.length === beforePick - 1 && + !afterDelete.some((e) => e.id === alarm?.id) && + (await transitions()).some((e) => e.id === alarm?.id && e.label === 'alarm'), + JSON.stringify({ beforePick, afterDelete }), + ); + // 26f. A behavior reads the machine: Set state on start, State branch every frame. const rig = await scaffoldBehavior(t, { assetId: 'sm-logic',