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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 08:59:19 +02:00
1 parent 631cf54c72
commit 1e2285c545
12 files changed
+366 -14

No files matched your search

+135
View File
@@ -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')`);
}