From 1e2285c5455b0d4e93824f1931e43ff30f42ce16 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 11 Sep 2026 08:59:19 +0200 Subject: [PATCH] Let a graph node collapse to its title bar A chevron in front of the title toggles it without touching the selection, and right-click offers Collapse node / Expand node for the whole selection as one undo step. A collapsed node draws its title bar alone; every link of one side meets at a single point on it, and hidden links there share one mark per side, since per-port marks would stack on the same pixels. It has no ports to grab, so a link dropped on it connects nothing. Like hiding a link this is drawing only: collapsed is saved on the node and an expanded node leaves no trace in the project file. Co-Authored-By: Claude Opus 5 --- README.md | 10 ++- src/core/commands/graphCommands.ts | 30 +++++++ src/core/graph/Graph.ts | 2 + src/core/store/serialize.ts | 1 + src/editor/debugHook.ts | 1 + src/editor/graph/GraphCanvas.ts | 30 ++++++- src/editor/graph/NodeView.ts | 72 +++++++++++++-- src/editor/graph/folding.ts | 9 +- tests/core/collapsed-node.test.ts | 74 ++++++++++++++++ todo-notes/done/node-collapse.md | 15 ++++ todo-notes/node-collapse.md | 1 - tools/e2e/06-graph.mjs | 135 +++++++++++++++++++++++++++++ 12 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 tests/core/collapsed-node.test.ts create mode 100644 todo-notes/done/node-collapse.md delete mode 100644 todo-notes/node-collapse.md diff --git a/README.md b/README.md index d36e8830..e8a1d4e1 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,12 @@ shown on the node and editable in the inspector. grows a little, is captioned `Add +2`, and one click selects *every* far end at once, which is also what lends all of its curves back; the menu counts what it acts on (*Show 3 links*), and does it as one undo step. +- **A node can be collapsed** to its title bar: the chevron in front of the title (it leaves the + selection alone, like the hierarchy's), or right-click → *Collapse node* / *Expand node*, which + acts on the whole selection as one undo step. Every link of one side then meets at a single + point on the title bar, and its hidden links share one mark per side. A collapsed node has no + ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link, + this is drawing only: `collapsed` on the node is saved with the project. - **Header bar** (the same one the Texture and Container tabs have): the behavior picker, **Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn @@ -578,7 +584,7 @@ src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editi src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar -src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links) +src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links, collapsed nodes) src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template) src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths) src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) @@ -613,7 +619,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # optional: run the browser elsewhere; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 509 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 527 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index b53105bc..88599e95 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -242,6 +242,36 @@ export class FoldEdgeCommand implements Command { } } +/** One undo step for the whole selection; every node is found before any of them changes. */ +export class CollapseNodesCommand implements Command { + readonly label: string; + private previous: boolean[] = []; + + constructor( + private readonly assetId: string, + private readonly nodeIds: readonly string[], + private readonly collapsed: boolean, + ) { + const noun = nodeIds.length === 1 ? 'node' : 'nodes'; + this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`; + } + + execute(project: Project): void { + const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id)); + this.previous = nodes.map((n) => n.collapsed === true); + for (const node of nodes) setCollapsed(node, this.collapsed); + } + + undo(project: Project): void { + this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!)); + } +} + +function setCollapsed(node: GraphNode, collapsed: boolean): void { + if (collapsed) node.collapsed = true; + else delete node.collapsed; +} + export class DisconnectCommand implements Command { readonly label = 'Disconnect'; private removed: Removed[] = []; diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 1ffbdaa8..9cf844c9 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -24,6 +24,8 @@ export interface GraphNode { * behavior its signature; an Expand/Pack node is what gives a struct its fields. */ ports?: DeclaredPort[]; + /** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */ + collapsed?: boolean; } /** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */ diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d755143a..cd61b9ad 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -244,6 +244,7 @@ function normalizeGraph(raw: unknown): Graph { const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params }; const ports = normalizePorts(n.ports); if (ports.length > 0) node.ports = ports; + if (n.collapsed === true) node.collapsed = true; graph.nodes.push(node); } const edgeIds = new Set(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0c4058a0..c3f2466e 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -191,6 +191,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.canvas.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), + chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId), nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId), nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId), nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId), diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 806a84d9..b56a1d08 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -4,6 +4,7 @@ import type { Command } from '../../core/commands/Command'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; import { AddNodeCommand, + CollapseNodesCommand, ConnectCommand, DisconnectCommand, FoldEdgeCommand, @@ -372,6 +373,11 @@ export class GraphCanvas extends Widget { this.redraw(); return; } + // Like the hierarchy's chevron: it folds, and leaves the selection alone. + if (view.chevronAt(local.x, local.y)) { + this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed)); + return; + } if (e.ctrlKey || e.metaKey || e.shiftKey) { this.state.toggleNode(this.assetId, view.node.id); return; @@ -595,7 +601,8 @@ export class GraphCanvas extends Widget { const hit = this.portUnder(globalX, globalY); if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; const node = this.nodeUnder(globalX, globalY); - return node ? dropTargetPort(graph, from, node, lookup) : null; + if (!node || this.views.get(node)?.collapsed) return null; + return dropTargetPort(graph, from, node, lookup); } private portUnder(globalX: number, globalY: number): PortSide | null { @@ -682,7 +689,21 @@ export class GraphCanvas extends Widget { if (!this.state.selectedNodeIds.includes(view.node.id)) this.state.select(nodeSelection(assetId, [view.node.id])); const ids = this.state.selectedNodeIds; + const nodes = this.selectedNodes(); + const expanded = nodes.filter((n) => !n.collapsed).map((n) => n.id); + const collapsed = nodes.filter((n) => n.collapsed).map((n) => n.id); + const fold = (list: string[], verb: string, to: boolean): MenuItem[] => + list.length === 0 + ? [] + : [ + { + label: list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`, + onSelect: () => this.state.execute(new CollapseNodesCommand(assetId, list, to)), + }, + ]; new Menu([ + ...fold(expanded, 'Collapse', true), + ...fold(collapsed, 'Expand', false), { label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node', danger: true, @@ -889,7 +910,7 @@ export class GraphCanvas extends Widget { const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node]; this.foldEnds.push({ edgeId: edge.id, - key: foldMarkKey(side, isOutput), + key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed), other, otherLabel, isOutput, @@ -1041,6 +1062,11 @@ export class GraphCanvas extends Widget { const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 }); return { x: g.x, y: g.y }; } + + chevronGlobal(nodeId: string): { x: number; y: number } | null { + const g = this.views.get(nodeId)?.chevronGlobal(); + return g ? { x: g.x, y: g.y } : null; + } } /** Horizontal S-curve between two points. */ diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index e067012f..a80c9629 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -28,6 +28,8 @@ export const PORT_RADIUS = 5; /** Distance (graph units) within which a press counts as hitting a port. */ export const PORT_HIT_RADIUS = 9; const PAD = 6; +/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */ +const CHEVRON_SLOT = 14; export function categoryColor(category: NodeCategory): number { const c = Theme.colors; @@ -86,6 +88,8 @@ export class NodeView extends Widget { targetPort: { port: string; isOutput: boolean } | null = null; /** Data inputs that currently have a connection (their inline value is hidden). */ private connected = new Set(); + /** Which sides carry a link – a collapsed node marks those with one dot each. */ + private linked = { inputs: false, outputs: false }; /** * Not fixed at construction: a nested-behavior node's ports come from another graph, so the @@ -137,12 +141,28 @@ export class NodeView extends Widget { .filter((p) => edgesInto(graph, node.id, p.key).length > 0) .map((p) => p.key), ); - const height = this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD; + this.linked = { + inputs: graph.edges.some((e) => e.to.node === node.id), + outputs: graph.edges.some((e) => e.from.node === node.id), + }; + const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD; this.rebuildTexts(); this.resize(NODE_WIDTH, height); this.position.set(node.x, node.y); } + get collapsed(): boolean { + return this.node.collapsed === true; + } + + chevronAt(localX: number, localY: number): boolean { + return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < PAD + CHEVRON_SLOT; + } + + chevronGlobal(): { x: number; y: number } { + return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 }); + } + private rowCount(): number { return Math.max(this.def.inputs.length, this.def.outputs.length); } @@ -168,15 +188,16 @@ export class NodeView extends Widget { const list = isOutput ? this.def.outputs : this.def.inputs; const index = list.findIndex((p) => p.key === key); if (index < 0) return null; - return { x: isOutput ? this.w : 0, y: this.rowY(index) }; + return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) }; } private rowY(index: number): number { return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2; } - /** Every port with its local centre – inputs on the left edge, outputs on the right. */ + /** Every port with its local centre – none while collapsed, so none can be grabbed. */ private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> { + if (this.collapsed) return; for (const [list, isOutput] of [ [this.def.inputs, false], [this.def.outputs, true], @@ -209,7 +230,15 @@ export class NodeView extends Widget { this.addChild(node); this.texts.push(node); }; - add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2); + add( + this.def.label, + 'accent', + PAD + CHEVRON_SLOT, + (TITLE_HEIGHT - 16) / 2, + 'left', + NODE_WIDTH - PAD * 2 - CHEVRON_SLOT, + ); + if (this.collapsed) return; let y = TITLE_HEIGHT + PAD; for (const line of this.paramLines) { add(line, 'small', PAD + 2, y + 2); @@ -256,9 +285,11 @@ export class NodeView extends Widget { this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node); this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category)); - this.box - .rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius) - .fill(categoryColor(this.def.category)); + if (!this.collapsed) + this.box + .rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius) + .fill(categoryColor(this.def.category)); + this.drawChevron(PAD + 4, TITLE_HEIGHT / 2); const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder; this.box .roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius) @@ -266,6 +297,16 @@ export class NodeView extends Widget { this.ports.clear(); const r = PORT_RADIUS; + if (this.collapsed) { + for (const [linked, x] of [ + [this.linked.inputs, 0], + [this.linked.outputs, this.w], + ] as const) { + if (!linked) continue; + this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted); + this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node }); + } + } for (const { port, isOutput, x, y } of this.portCentres()) { if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) { // A gap in the node colour first, so the ring stays visible on the white exec pin too. @@ -288,6 +329,23 @@ export class NodeView extends Widget { } } } + + /** The hierarchy's shape, so both folds read the same. */ + private drawChevron(x: number, y: number): void { + const color = Theme.colors.text; + if (this.collapsed) + this.box + .moveTo(x - 1, y - 4) + .lineTo(x + 4, y) + .lineTo(x - 1, y + 4) + .fill(color); + else + this.box + .moveTo(x - 3, y - 2) + .lineTo(x + 5, y - 2) + .lineTo(x + 1, y + 3) + .fill(color); + } } /** The value of a setting as shown on the node; references that no longer resolve get a "?". */ diff --git a/src/editor/graph/folding.ts b/src/editor/graph/folding.ts index 1cbcb380..d2dcdf0c 100644 --- a/src/editor/graph/folding.ts +++ b/src/editor/graph/folding.ts @@ -13,9 +13,14 @@ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet, * Which mark a folded edge end belongs to. One port, one mark: drawn per edge instead, several * hidden links out of the same port stack their stub, dot and name on the very same pixels. * The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c". + * A collapsed node draws all its ports on one point per side, so there it is one mark per side. */ -export function foldMarkKey(side: { node: string; port: string }, isOutput: boolean): string { - return `${side.node}\0${side.port}\0${isOutput ? 'out' : 'in'}`; +export function foldMarkKey( + side: { node: string; port: string }, + isOutput: boolean, + collapsed = false, +): string { + return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`; } /** One name plus a count: three names do not fit, and the count is what says "there is more here". */ diff --git a/tests/core/collapsed-node.test.ts b/tests/core/collapsed-node.test.ts new file mode 100644 index 00000000..7c320211 --- /dev/null +++ b/tests/core/collapsed-node.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { CommandError } from '../../src/core/commands/Command'; +import { CollapseNodesCommand } from '../../src/core/commands/graphCommands'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { foldMarkKey } from '../../src/editor/graph/folding'; +import { graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; + +function threeNodes() { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'Mover'); + const edit = graphEditor(fixture, behavior); + const start = edit.addNode('OnStart'); + const self = edit.addNode('Self'); + const move = edit.addNode('Move', { dx: 7 }); + return { ...fixture, behavior, nodes: { start, self, move } }; +} + +describe('collapsing nodes', () => { + it('takes a whole selection back in one undo, each node to the state it really had', () => { + const { stack, behavior, nodes } = threeNodes(); + stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id], true)); + stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id, nodes.move.id], true)); + expect(nodes.move.collapsed).toBe(true); + + stack.undo(); + expect(nodes.self.collapsed).toBe(true); + expect(nodes.move.collapsed).toBeUndefined(); + }); + + it('changes no node when one of them is missing', () => { + const { project, behavior, nodes } = threeNodes(); + const command = new CollapseNodesCommand(behavior.id, [nodes.self.id, 'no-such-node'], true); + expect(() => command.execute(project)).toThrow(CommandError); + expect(nodes.self.collapsed).toBeUndefined(); + }); + + it('survives the save, and expanding leaves no trace in the file', () => { + const { project, stack, behavior, nodes } = threeNodes(); + stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], true)); + const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; + expect(loaded.graph.nodes.find((n) => n.id === nodes.move.id)!.collapsed).toBe(true); + expect('collapsed' in loaded.graph.nodes.find((n) => n.id === nodes.self.id)!).toBe(false); + + stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], false)); + const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; + expect(again.graph.nodes.every((n) => !('collapsed' in n))).toBe(true); + }); + + it('ignores a collapsed flag in the file that is not literally true', () => { + const { project, behavior } = threeNodes(); + const raw = serializeProject(project) as { + assets: { id: string; graph?: { nodes: Record[] } }[]; + }; + const stored = raw.assets.find((a) => a.id === behavior.id)!.graph!.nodes; + stored[0]!.collapsed = 'yes'; + stored[1]!.collapsed = 1; + const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset; + expect(loaded.graph.nodes.every((n) => !('collapsed' in n))).toBe(true); + }); +}); + +describe('hidden links at a collapsed node', () => { + it('share one mark per side, because every port sits on the same point', () => { + const a = foldMarkKey({ node: 'n', port: 'x' }, false, true); + expect(foldMarkKey({ node: 'n', port: 'y' }, false, true)).toBe(a); + expect(foldMarkKey({ node: 'n', port: 'x' }, true, true)).not.toBe(a); + expect(foldMarkKey({ node: 'm', port: 'x' }, false, true)).not.toBe(a); + expect(foldMarkKey({ node: 'n', port: 'x' }, false)).not.toBe( + foldMarkKey({ node: 'n', port: 'y' }, false), + ); + }); +}); diff --git a/todo-notes/done/node-collapse.md b/todo-notes/done/node-collapse.md new file mode 100644 index 00000000..091b814c --- /dev/null +++ b/todo-notes/done/node-collapse.md @@ -0,0 +1,15 @@ +Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden. + +--- + +Gebaut. Zwei Dinge, die weder oben stehen noch aus dem Commit hervorgehen: + +**Zugeklappt ist reine Darstellung, wie ein versteckter Link.** Der Graph bleibt derselbe, +`collapsed` am Node wird nur gespeichert, und ein aufgeklappter Node hinterlässt keine Spur in der +Datei. Weil ein zugeklappter Node keine Ports zum Greifen hat, verbindet ein darauf fallengelassener +Link nichts. Neue Kanten gibt es erst nach dem Aufklappen, statt einen Port zu raten. + +**Versteckte Links teilen sich am zugeklappten Node eine Marke pro Seite statt pro Port.** Alle +Ports einer Seite liegen dort auf einem Punkt. Mit einer Marke pro Port stapelten sich Stummel, +Punkt und Name auf denselben Pixeln (gemessen: zwei Marken mit je `links: 1`, identische +Koordinaten). diff --git a/todo-notes/node-collapse.md b/todo-notes/node-collapse.md deleted file mode 100644 index 32468a6e..00000000 --- a/todo-notes/node-collapse.md +++ /dev/null @@ -1 +0,0 @@ -Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden. diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 8f22239b..a86b6ac1 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -22,6 +22,9 @@ export async function graph(t) { select, stop, asset, + api, + navigate, + projectId, } = t; const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id); const graphCounts = async () => { @@ -739,5 +742,137 @@ export async function graph(t) { await undo(); await sleep(200); + // 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new + // link lands on it until it is expanded, and the state is saved with the project. + await select({ kind: 'asset', assetId: 'mover' }); + await clickText('Fit'); + await sleep(250); + const edgesBefore13q = (await graphCounts()).edges; + const openRect = await json(`__gm.graph.nodeRect('n-move')`); + const chevron = await json(`__gm.graph.chevronAt('n-move')`); + await click(chevron.x, chevron.y); + await sleep(200); + const shut = await json(`__gm.graph.nodeRect('n-move')`); + check( + 'the chevron collapses the node to its title bar, and leaves the selection alone', + (await nodeOf('n-move')).collapsed === true && + Math.abs(shut.h / shut.w - 24 / 176) < 0.01 && + (await json('__gm.graph.selected()')).length === 0, + JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }), + ); + const title = await json(`__gm.graph.nodeAt('n-move')`); + const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`); + const inputs = [ + await anchorOf('exec', false), + await anchorOf('object', false), + await anchorOf('dy', false), + ]; + const output = await anchorOf('exec', true); + check( + 'every link meets at one point per side, level with the title', + inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) && + Math.abs(output.x - (shut.x + shut.w)) < 1 && + Math.abs(output.y - title.y) < 1, + JSON.stringify({ inputs, output, title, shut }), + ); + check( + 'the links are still there and still drawn', + (await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true, + ); + await shot('24b-node-collapsed'); + + const speedOut = await json(`__gm.graph.portAt('n-speed', 'value', true)`); + await mouse('mouseMoved', speedOut.x, speedOut.y); + await mouse('mousePressed', speedOut.x, speedOut.y); + await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 }); + await sleep(50); + const aimed = await json('__gm.graph.dropTarget()'); + await mouse('mouseReleased', title.x, title.y); + await sleep(150); + await press('Escape'); + check( + 'a link dropped on a collapsed node goes nowhere', + aimed === null && (await graphCounts()).edges === edgesBefore13q, + JSON.stringify(aimed), + ); + + for (const port of ['x', 'y']) { + const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`); + await click(at.x, at.y, 'right'); + await sleep(150); + await clickText('Hide link'); + await sleep(200); + } + await select({ kind: 'asset', assetId: 'mover' }); + await sleep(200); + const markDx = await json(`__gm.graph.foldMark('e3', false)`); + const markDy = await json(`__gm.graph.foldMark('e4', false)`); + check( + 'hidden links into a collapsed node share one mark instead of stacking', + markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2, + JSON.stringify({ markDx, markDy }), + ); + await undo(); + await undo(); + + await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); + await sleep(150); + await click(title.x, title.y, 'right'); + await sleep(150); + check( + 'a mixed selection is offered both ways', + Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) && + Boolean(await evaluate(`__gm.locate('Expand node', false)`)), + ); + await press('Escape'); + await undo(); + await sleep(150); + check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined); + + await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] }); + await sleep(150); + const openTitle = await json(`__gm.graph.nodeAt('n-move')`); + await click(openTitle.x, openTitle.y, 'right'); + await sleep(150); + const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`); + check('two expanded nodes are offered as one', collapseBoth !== null); + if (collapseBoth) await click(collapseBoth.x, collapseBoth.y); + else await press('Escape'); + await sleep(200); + const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed]; + check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]'); + await undo(); + await sleep(150); + check( + 'and one undo takes all of it back', + JSON.stringify(await both()) === '[null,null]', + JSON.stringify(await both()), + ); + + // Saved and loaded: a reload, not just the file, because the drawing is what has to come back. + await select({ kind: 'asset', assetId: 'mover' }); + const again = await json(`__gm.graph.chevronAt('n-move')`); + await click(again.x, again.y); + const savedMove = async () => { + for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300); + const saved = await api(`projects/${projectId}`); + return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move'); + }; + check('the collapse reaches the saved file', (await savedMove()).collapsed === true); + await navigate(); + await sleep(500); + await select({ kind: 'asset', assetId: 'mover' }); + await evaluate(`__gm.showTab('graph')`); + await sleep(250); + const reloaded = await json(`__gm.graph.nodeRect('n-move')`); + check( + 'and a reloaded project draws it collapsed again', + (await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - 24 / 176) < 0.01, + JSON.stringify(reloaded), + ); + const reopen = await json(`__gm.graph.chevronAt('n-move')`); + await click(reopen.x, reopen.y); + check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); + await evaluate(`__gm.showTab('scene')`); }