Title a collapsed Variables or Properties node by what it does

Collapsed, the node reads "Set 2 variables", "Get width" or "No properties";
expanded it is always "Variables" / "Properties". The Properties node shows
its object type (and behavior) as a line under the title instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 19:53:43 +02:00
1 parent 58f19a3451
commit 0376ae175d
11 files changed
+182 -69

No files matched your search

+11 -8
View File
@@ -106,9 +106,8 @@ shown on the node and editable in the inspector.
its flow pins only while at least one is. Unconnected and with nothing typed, it writes the zero
of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read
first, so one node swaps two variables. `count = count + 1` is one `Variables` node feeding its
own output through an `Add` back into its input. With a single variable the node is titled by it
(*speed*). The ports are picked from the behavior's variables rather than named, they take their
type from the declaration, and renaming a variable carries them (and their edges) along. Files
own output through an `Add` back into its input. The ports are picked from the behavior's
variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along. Files
from before this node still load: their `Get/Set variable(s)` and `Variable` nodes become
`Variables` nodes that read or write the same variables. `Properties` is the same node for the
object it is handed (fixed input `object`). Its **Type** setting, above the list, decides what
@@ -117,9 +116,9 @@ shown on the node and editable in the inspector.
port along, in any graph), and *Any* offers the kinds (Number, Text, …) and leaves the name to be
typed. A picked property keeps its field and default – an unconnected `color` is a colour field
starting white, shown on the node as `#ffffff`, and a camera's `width` is its whole-pixel view
width. Switching the type keeps the ports; one the new type lacks is marked. The node is titled
by its type or behavior (*Sprite properties*, *Game properties*). Textures and scenes are no type
here: they are not objects in a running game. Older files load their `Get/Set property(ies)`, `Set enabled` and `Set text` as
width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its
type as a setting row (*Type: Sprite*, plus *Behavior: Game* under that type). Textures and
scenes are no type here: they are not objects in a running game. Older files load their `Get/Set property(ies)`, `Set enabled` and `Set text` as
`Properties` nodes, typed by the first object type holding every key, else the one behavior
declaring them, else Any – which is also what a node only about `enabled` gets, since every
object has it. A variable is a
@@ -294,7 +293,11 @@ shown on the node and editable in the inspector.
point on the title bar, and its hidden links share one mark per side. It narrows to its title
(never wider than an open node, where a long title is cut). 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.
this is drawing only: `collapsed` on the node is saved with the project. A collapsed
`Variables` or `Properties` node, titled just that while open, is titled by what it does, since
its ports are out of sight:
*Set 2 variables* when more than one is written, else *Get 2 variables* when more than one is
only read, else *Set speed* / *Get speed*, and *No variables* when it has none.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
height – `editor/framing.ts`), and the size of the graph as "N nodes · M links".
@@ -667,7 +670,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 # 575 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 578 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
+11
View File
@@ -51,6 +51,17 @@ export function declaredOn(
return def.declaresPorts === side ? [...ports] : [];
}
/** What a collapsed Variables or Properties node does, since its ports are out of sight. */
export function collapsedTitle(ports: readonly DeclaredPort[], noun: string): string {
const set = ports.filter((p) => p.write);
const get = ports.filter((p) => !p.write);
if (set.length > 1) return `Set ${set.length} ${noun}`;
if (get.length > 1) return `Get ${get.length} ${noun}`;
if (set[0]) return `Set ${set[0].key}`;
if (get[0]) return `Get ${get[0].key}`;
return `No ${noun}`;
}
/** The single start (or end) node of a graph; a second one is ignored. */
export function terminalNode(graph: Graph, type: string): GraphNode | undefined {
return graph.nodes.find((n) => n.type === type);
+7 -7
View File
@@ -1,10 +1,10 @@
import { PROPERTIES } from '../../nested';
import { collapsedTitle, PROPERTIES } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import {
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
propertiesTitle,
propertyPort,
propertyTargetOf,
} from '../../propertyPorts';
import { asEntity, writeTyped } from '../../values';
import { exec, obj } from '../shared';
@@ -19,13 +19,13 @@ export const Properties: NodeDef = {
inputs: [obj('object')],
outputs: [],
params: [OBJECT_TYPE_SETTING, OBJECT_BEHAVIOR_SETTING],
// The title names both ("Sprite properties", "Game properties").
hiddenParams: () => [OBJECT_TYPE_SETTING.key, OBJECT_BEHAVIOR_SETTING.key],
hiddenParams: (params) => (propertyTargetOf(params) === 'behavior' ? [] : [OBJECT_BEHAVIOR_SETTING.key]),
declaresPorts: 'both',
declaredPort: (port, node) => propertyPort(port, node.params),
derivePorts: ({ node, behaviors }) => {
const label = propertiesTitle(node, behaviors);
return (node.ports ?? []).some((p) => p.write) ? { label, inputs: [exec()], outputs: [exec()] } : { label };
derivePorts: ({ node }) => {
const ports = node.ports ?? [];
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'properties') } : shape;
},
read: (_ctx, _params, input) => asEntity(input('object'))?.props ?? {},
run: (_ctx, inputs, _params, def) => {
+2 -3
View File
@@ -1,4 +1,4 @@
import { portDefOf, VARIABLES } from '../../nested';
import { collapsedTitle, portDefOf, VARIABLES } from '../../nested';
import { type NodeDef, withInlineDefault } from '../../NodeRegistry';
import { writeTyped } from '../../values';
import { exec } from '../shared';
@@ -21,9 +21,8 @@ export const Variables: NodeDef = {
declaredPort: (port) => withInlineDefault(portDefOf(port)),
derivePorts: ({ node }) => {
const ports = node.ports ?? [];
const only = ports.length === 1 ? ports[0]?.key : undefined;
const shape = ports.some((p) => p.write) ? { inputs: [exec()], outputs: [exec()] } : {};
return only ? { ...shape, label: only } : shape;
return node.collapsed ? { ...shape, label: collapsedTitle(ports, 'variables') } : shape;
},
read: (ctx) => ctx.variables,
run: (ctx, inputs, _params, def) => {
+1 -9
View File
@@ -1,8 +1,7 @@
import { ASSET_TYPE_LABELS } from '../model/AssetTypes';
import { type PropertyBag, textOf } from '../model/Properties';
import { type PropertyDef, type PropertySchema, schemaFor } from '../properties/PropertySchema';
import type { DeclaredPort, GraphNode } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { DeclaredPort } from './Graph';
import { portDefOf } from './nested';
import { type PortDef, withInlineDefault } from './NodeRegistry';
import { portKindOf } from './variablePorts';
@@ -63,10 +62,3 @@ export function propertyPort(port: DeclaredPort, params: PropertyBag): PortDef {
if (!field || portKindOf(field) !== port.kind) return plain;
return { ...plain, field, default: field.default };
}
export function propertiesTitle(node: GraphNode, behaviors: BehaviorLookup): string {
const target = propertyTargetOf(node.params);
if (target === 'any') return 'Properties';
const behavior = target === 'behavior' ? behaviors(textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '')) : null;
return `${behavior?.name ?? targetLabel(target)} properties`;
}
+1
View File
@@ -384,6 +384,7 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s
if (def.kind === 'asset') return assetRefLabel(project, asString(value));
if (def.kind === 'texture') return textureRefLabel(project, asString(value));
if (def.kind === 'color') return toHex(asNumber(value));
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
return formatValue(value);
}
+39 -10
View File
@@ -4,6 +4,7 @@
*/
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
import {
@@ -141,18 +142,46 @@ describe('what a port carries, by the type of the node', () => {
expect(propertyPort({ key: 'ready', kind: 'boolean' }, {}).default).toBe(false);
});
it('names the node after its type, or after the behavior it points at', () => {
it('shows its type as a setting row, and the behavior only under that type', () => {
const fixture = makeProject();
const game = makeBehavior(fixture, 'Game');
const edit = graphEditor(fixture, makeBehavior(fixture, 'Player'));
const title = (params: PropertyBag) =>
resolveDef(edit.properties([], params), game.graph, behaviorLookup(fixture.project))!.label;
expect(title({})).toBe('Properties');
expect(title(SPRITE)).toBe('Sprite properties');
expect(title({ [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id })).toBe(
'Game properties',
const edit = graphEditor(fixture, makeBehavior(fixture, 'B'));
const hidden = (params: PropertyBag) => {
const node = edit.properties([], params);
return resolveDef(node, { nodes: [node], edges: [] }, NO_BEHAVIORS)!.hiddenParams?.(node.params);
};
expect(hidden(SPRITE)).toEqual([OBJECT_BEHAVIOR_PARAM]);
expect(hidden({})).toEqual([OBJECT_BEHAVIOR_PARAM]);
expect(hidden({ [OBJECT_TYPE_PARAM]: 'behavior' })).toEqual([]);
});
it('is titled Properties while open and says what it does once collapsed', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
const edit = graphEditor(fixture, behavior);
const width = { key: 'width', kind: 'number' } as const;
const nodes = [
edit.properties([set('width'), set('height')], SPRITE),
edit.properties([width, { key: 'height', kind: 'number' }], SPRITE),
edit.properties([set('width')], SPRITE),
edit.properties([width], SPRITE),
edit.properties([], SPRITE),
];
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_BEHAVIORS)!.label);
expect(new Set(titles())).toEqual(new Set(['Properties']));
fixture.stack.execute(
new CollapseNodesCommand(
behavior.id,
nodes.map((node) => node.id),
true,
),
);
expect(title({ [OBJECT_TYPE_PARAM]: 'behavior' })).toBe('Behavior properties');
expect(titles()).toEqual([
'Set 2 properties',
'Get 2 properties',
'Set width',
'Get width',
'No properties',
]);
});
});
+30 -14
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type GraphNode, GraphInterpreter, inlineValue, resolveDef } from '../../src/core/graph';
@@ -238,22 +239,37 @@ describe('Variables node titles', () => {
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
resolveDef(node, behavior.graph, () => null)!.label;
it('name a single variable, and stay plain for none or several', () => {
const { behavior, edit } = setup();
const one = edit.variables(['count'], { write: true });
const none = edit.variables([]);
const two = edit.variables(['count', 'copy']);
expect([one, none, two].map((node) => titleOf(behavior, node))).toEqual([
'count',
'Variables',
'Variables',
]);
});
it('follow a renamed variable', () => {
it('follow a renamed variable once collapsed', () => {
const { fixture, behavior, edit } = setup();
const node = edit.variables(['count']);
fixture.stack.execute(new CollapseNodesCommand(behavior.id, [node.id], true));
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score'));
expect(titleOf(behavior, node)).toBe('score');
expect(titleOf(behavior, node)).toBe('Get score');
});
it('say what the node does once it is collapsed, and only then', () => {
const { fixture, behavior, edit } = setup(['a', 'b', 'c']);
const nodes = [
edit.variables(['a', 'b', 'c'], { write: ['a', 'b'] }),
edit.variables(['a', 'b', 'c'], { write: ['a'] }),
edit.variables(['a', 'b'], { write: ['a'] }),
edit.variables(['a'], { write: true }),
edit.variables(['a']),
edit.variables([]),
];
const open = nodes.map((node) => titleOf(behavior, node));
const ids = nodes.map((node) => node.id);
fixture.stack.execute(new CollapseNodesCommand(behavior.id, ids, true));
expect(nodes.map((node) => titleOf(behavior, node))).toEqual([
'Set 2 variables',
'Get 2 variables',
'Set a',
'Set a',
'Get a',
'No variables',
]);
fixture.stack.undo();
expect(nodes.map((node) => titleOf(behavior, node))).toEqual(open);
expect(new Set(open)).toEqual(new Set(['Variables']));
});
});
@@ -6,3 +6,7 @@ ok, title bei variables node NUR wenn zusammengeklappt ändern:
- ansonsten => "No variables"
dasselbe bei properties node
---
Umgesetzt: aufgeklappt heissen die Nodes immer `Variables` / `Properties`. Der Typ des `Properties`-Nodes steht deshalb als Zeile unter dem Titel (`Type: Sprite`, bei Behavior zusaetzlich `Behavior: <Name>`).
„Get {n}“ zaehlt nur Ports ohne Set.
+52 -7
View File
@@ -186,7 +186,53 @@ export async function variableNodes(t) {
await sleep(300);
check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx');
// 23f. A Pack keeps declaring its own fields, but a variable that already carries the shape can
// 23f. Collapsed, the ports are out of sight, so the title says what the node does – and only then.
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
const toggle = async (id) => {
const chevron = await json(`__gm.graph.chevronAt(${JSON.stringify(id)})`);
await click(chevron.x, chevron.y);
await sleep(250);
};
/** `locate` matches exactly, so a title cut short by a too-narrow node is not found. */
const drawnWhole = async (text) => {
const canvas = await json('__gm.graph.canvasRect()');
const hits = await json(`__gm.locateAll(${JSON.stringify(text)})`);
return hits.some((p) => p.x > canvas.x && p.x < canvas.x + canvas.w);
};
await toggle('vp-set');
await toggle('vp-get');
const shut = { set: await titleOf('vp-set'), get: await titleOf('vp-get') };
check(
'collapsed, a node writing two variables says so, and one only reading two says that',
shut.set === 'Set 2 variables' &&
shut.get === 'Get 2 variables' &&
(await drawnWhole('Set 2 variables')) &&
(await drawnWhole('Get 2 variables')),
JSON.stringify(shut),
);
await shot('02b-collapsed-titles');
const narrow = (await json(`__gm.graph.nodeRect('vp-get')`)).w;
await clickLastSet();
const switched = await titleOf('vp-get');
check(
'switching one to Set while collapsed retitles it and resizes it to the new title',
switched === 'Set vy' &&
(await drawnWhole('Set vy')) &&
(await json(`__gm.graph.nodeRect('vp-get')`)).w < narrow,
JSON.stringify({ switched, narrow }),
);
await undo();
await sleep(300);
await toggle('vp-set');
await toggle('vp-get');
const open = [await titleOf('vp-set'), await titleOf('vp-get')];
check(
'expanded again, both are plain Variables nodes',
open.join() === 'Variables,Variables',
JSON.stringify(open),
);
// 23g. A Pack keeps declaring its own fields, but a variable that already carries the shape can
// hand it over. A color field arrives as a number port – the graph has no colors.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'vp-logic');
@@ -232,7 +278,7 @@ export async function variableNodes(t) {
await sleep(300);
check('one undo takes all of them back', (await portsOf('vp-pack')).length === 0);
// 23g. A list of structs declares the shape of its items, which is the bundle a Pack builds for it.
// 23h. A list of structs declares the shape of its items, which is the bundle a Pack builds for it.
await click(from.x, from.y);
await sleep(200);
await clickLast('shapes (item)');
@@ -246,7 +292,7 @@ export async function variableNodes(t) {
await undo();
await sleep(300);
// 23h. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
// 23i. A behavior's own inputs are not fields of a bundle, so the helper stays out of that section.
await mutate(`
__gm.state.project.assets.find((x) => x.id === 'vp-logic').graph.nodes.push(
${JSON.stringify(node('vp-start', 'BehaviorStart', 560, 600))},
@@ -262,7 +308,7 @@ export async function variableNodes(t) {
JSON.stringify(helper),
);
// 23i. From the palette the node comes empty; picking a variable titles it, and switched to Set
// 23j. From the palette the node comes empty; picking a variable keeps its title, and switched to Set
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
@@ -278,7 +324,6 @@ export async function variableNodes(t) {
const added = (await asset('vp-logic')).graph.nodes.find((n) => !idsBefore.includes(n.id));
check('the palette offers the Variables node', added?.type === 'Variables', JSON.stringify(added));
const variableId = added?.id ?? '';
const titleOf = (id) => json(`__gm.graph.nodeLabel(${JSON.stringify(id)})`);
const untitled = await titleOf(variableId);
const bare = !(await portAt(variableId, 'exec', false));
await clickLast('+ Variable');
@@ -286,8 +331,8 @@ export async function variableNodes(t) {
await sleep(300);
await clickLastSet();
check(
'it starts without pins, and picking its variable makes that the title',
untitled === 'Variables' && bare && (await titleOf(variableId)) === 'vz',
'it starts without pins, and picking its variable leaves the title Variables',
untitled === 'Variables' && bare && (await titleOf(variableId)) === 'Variables',
JSON.stringify({ untitled, bare, titled: await titleOf(variableId) }),
);
check('switched to Set it grows its flow pins', Boolean(await portAt(variableId, 'exec', false)));
+24 -11
View File
@@ -36,6 +36,7 @@ export async function propertyNodes(t) {
(p) => `${p.key}:${p.kind}${p.write ? ':set' : ''}`,
);
const title = (nodeId) => json(`__gm.graph.nodeLabel(${JSON.stringify(nodeId)})`);
const settings = async (nodeId) => (await json(`__gm.graph.nodeSettings(${JSON.stringify(nodeId)})`)) ?? [];
const select = async (nodeId) => {
await evaluate(
`__gm.state.select({ kind: 'node', assetId: 'pp-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
@@ -111,14 +112,22 @@ export async function propertyNodes(t) {
JSON.stringify({ width: box?.width, color: box?.color, seen }),
);
// 24c. Named after its type; the type is chosen above the list it decides.
// 24c. Titled Properties; the type stands on the node as a setting row, chosen above the list it decides.
await select('pp-set');
const typeRow = await json(`__gm.inspectorField('Type', true)`);
const header = await locate('PROPERTIES', true);
check(
'the node is named after its type, chosen above its properties',
(await title('pp-set')) === 'Sprite properties' && typeRow && typeRow.y < header.y,
JSON.stringify({ title: await title('pp-set'), type: typeRow?.y, header: header.y }),
'the node is titled Properties and shows its type, chosen above its properties',
(await title('pp-set')) === 'Properties' &&
(await settings('pp-set')).join() === 'Type: Sprite' &&
typeRow &&
typeRow.y < header.y,
JSON.stringify({
title: await title('pp-set'),
rows: await settings('pp-set'),
type: typeRow?.y,
header: header.y,
}),
);
check(
'the node shows a typed-in color as a color',
@@ -165,9 +174,11 @@ export async function propertyNodes(t) {
await choose('Type', 'Camera');
const camera = await offered();
check(
'switching to Camera renames the node and offers the fields of a camera',
(await title('pp-set')) === 'Camera properties' && camera.includes('zoom') && !camera.includes('texture'),
JSON.stringify({ title: await title('pp-set'), offered: camera }),
'switching to Camera shows it on the node and offers the fields of a camera',
(await settings('pp-set')).join() === 'Type: Camera' &&
camera.includes('zoom') &&
!camera.includes('texture'),
JSON.stringify({ rows: await settings('pp-set'), offered: camera }),
);
check(
'a port the new type does not have stays, marked',
@@ -177,7 +188,7 @@ export async function propertyNodes(t) {
await shot('03-camera');
await undo();
await sleep(300);
check('undo switches back', (await title('pp-set')) === 'Sprite properties', await title('pp-set'));
check('undo switches back', (await settings('pp-set')).join() === 'Type: Sprite', await settings('pp-set'));
// 24g. Any names its ports by hand, and the kind is picked when adding one. A name that happens
// to be a property's stays of the kind picked.
@@ -218,9 +229,11 @@ export async function propertyNodes(t) {
await choose('Behavior', 'Reshape (behavior)');
const vars = await offered();
check(
'picking the behavior offers its variables and names the node after it',
vars.includes('seen') && vars.includes('enabled') && (await title('pp-beh')) === 'Reshape properties',
JSON.stringify({ title: await title('pp-beh'), offered: vars }),
'picking the behavior offers its variables and shows it on the node',
vars.includes('seen') &&
vars.includes('enabled') &&
(await settings('pp-beh')).join() === 'Type: Behavior,Behavior: Reshape',
JSON.stringify({ rows: await settings('pp-beh'), offered: vars }),
);
await addProperty('seen');
check('a variable is picked as a port', (await portsOf('pp-beh')).join() === 'seen:number');