Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors

Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.

The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.

Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.

The "Add node..." header button is gone; nodes come from the context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 15:07:07 +02:00
1 parent e9fa592078
commit 854ae2e756
35 files changed
+1044 -101

No files matched your search

+100 -2
View File
@@ -266,9 +266,16 @@ export async function nestedBehavior(t) {
`nodes ${beforeSelf.length} → ${afterSelf.length}, notice=${JSON.stringify(notice)} row=${JSON.stringify(selfRow)} dragging=${JSON.stringify(dragging)}`,
);
// 19j. Double-clicking a call opens the behavior it runs.
// 19j. Double-clicking a call opens the behavior it runs. Double calls Empty in turn, so the
// breadcrumb below gets a third level. The call sits far down: only a graph tall enough to
// fill the canvas shows whether it was fitted to the canvas before or after the bar.
await mutate(`
const double = __gm.state.project.assets.find((a) => a.id === 'nb-double');
double.graph.nodes.push(${JSON.stringify(node('nb-call2', 'RunBehavior', 240, 900, { behavior: 'nb-empty' }))});
`);
const callNode = await json(`__gm.graph.nodeAt('nb-call')`);
check('the graph shown is still the caller', (await evaluate('__gm.graph.current()')) === 'nb-caller');
check('no breadcrumb on a graph opened from the outside', (await json('__gm.graph.crumbs()')).length === 0);
await t.doubleClick(callNode.x, callNode.y);
await sleep(400);
const opened = await evaluate('__gm.graph.current()');
@@ -278,7 +285,98 @@ export async function nestedBehavior(t) {
`current=${JSON.stringify(opened)}`,
);
// 19k. Clean up: the three behaviors and the box are gone again.
// 19k. The breadcrumb: every behavior entered by double-click, each earlier one a way back.
const crumbs = () => json('__gm.graph.crumbs()');
const trail = async () => (await crumbs()).map((c) => `${c.name}${c.clickable ? '' : '*'}`).join(' › ');
check('opening a call shows the way back', (await trail()) === 'Caller › Double*', await trail());
const cut = await json(`(() => {
const c = __gm.graph.canvasRect();
return __gm.state.project.assets.find((a) => a.id === 'nb-double').graph.nodes
.map((n) => ({ id: n.id, r: __gm.graph.nodeRect(n.id) }))
.filter(({ r }) => r.y < c.y || r.y + r.h > c.y + c.h)
.map(({ id }) => id);
})()`);
check('the graph opened is fitted below the bar, not under it', cut.length === 0, `cut off: ${cut}`);
const inner = await json(`__gm.graph.nodeAt('nb-call2')`);
await click(inner.x, inner.y);
// Past the double-click window: the pair below must count as its own two presses.
await sleep(500);
check(
'selecting a node inside keeps the breadcrumb',
(await trail()) === 'Caller › Double*',
await trail(),
);
await t.doubleClick(inner.x, inner.y);
await sleep(400);
check(
'each call opened further down adds a crumb',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await trail()) === 'Caller › Double › Empty*',
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// A width of its own too: crumbs never laid out are all 0 wide at one spot, and overlap nothing.
const overlaps = (await crumbs()).filter(
(c, i, all) => c.right - c.left < 10 || (i > 0 && c.left < all[i - 1].right),
);
check(
'the crumbs sit side by side, not on top of each other',
overlaps.length === 0,
JSON.stringify((await crumbs()).map((c) => [c.name, Math.round(c.left), Math.round(c.right)])),
);
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Twice'`);
check('a renamed behavior renames its crumb', (await trail()) === 'Caller › Twice › Empty*', await trail());
await mutate(`__gm.state.project.assets.find((a) => a.id === 'nb-double').name = 'Double'`);
await shot('39b-breadcrumb');
const clickCrumb = async (name) => {
const crumb = (await crumbs()).find((c) => c.name === name);
await click(crumb.x, crumb.y);
await sleep(300);
};
await clickCrumb('Double');
await shot('39c-breadcrumb-back');
check(
'a crumb leads back, and cuts the trail there',
(await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*',
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
await clickCrumb('Caller');
check(
'back at the root, the breadcrumb is gone',
(await evaluate('__gm.graph.current()')) === 'nb-caller' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// Opening another behavior any other way ends the trail: the asset panel, then the picker.
const enterDouble = async () => {
const call = await json(`__gm.graph.nodeAt('nb-call')`);
await t.doubleClick(call.x, call.y);
await sleep(400);
};
await enterDouble();
const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
await click(emptyAgain.x, emptyAgain.y);
await sleep(300);
check(
'choosing another behavior in the asset panel ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'nb-caller' })`);
await sleep(200);
await enterDouble();
const picker = await json('__gm.graph.pickerAt()');
await click(picker.x, picker.y);
await sleep(150);
const emptyItem = await locate('Empty', true);
await click(emptyItem.x, emptyItem.y);
await sleep(300);
check(
'choosing another behavior in the picker ends the trail',
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
);
// 19l. Clean up: the three behaviors and the box are gone again.
await evaluate(`__gm.state.select(null)`);
await mutate(`
const p = __gm.state.project;