diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 40809b8c..6a69293e 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -1,4 +1,4 @@ -import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { isList, type PropertyBag, type PropertyValue } from '../model/Properties'; import { isKindWith, plainKindOf } from '../properties/kinds'; import { flatDef, type PropertyDef } from '../properties/PropertySchema'; import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph'; @@ -11,7 +11,7 @@ export interface PortDef { key: string; label: string; type: PortType; - /** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */ + /** Inline value used while the input is unconnected and the node has no own value (else 0/''/false). */ default?: PropertyValue; /** The property this port carries; its typed-in value is edited as that property (color, enum, …). */ field?: PropertyDef; @@ -200,7 +200,24 @@ export function inlineInputDef(port: PortDef): PropertyDef { return def.kind === 'number' ? { ...def, step: 1 } : def; } -/** The value an unconnected input carries: the node's own inline value, else the port default. */ +/** + * The value an unconnected input carries: the node's own inline value, else the default the + * inspector shows for it – `false`, `0`, `""`, an empty list –, so what the node reads is what the + * editor says it reads. Only an input with nothing to type in (object, struct, exec) is `null`. + */ export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value { - return node.params[port.key] ?? port.default ?? null; + return node.params[port.key] ?? port.default ?? shownDefault(port); +} + +/** Cached per port: the interpreter asks for it on every read of an unconnected input. */ +const shownDefaults = new WeakMap(); + +function shownDefault(port: PortDef): Value { + let value = shownDefaults.get(port); + if (value === undefined) { + value = isInlinePort(port) ? inlineInputDef(port).default : null; + shownDefaults.set(port, value); + } + // A list is handed out fresh: a node that edits its input in place must not edit every other one. + return isList(value) ? [...value] : value; } diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index ba2562de..b0357757 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -184,10 +184,20 @@ export class NodeView extends Widget { .map((p) => p.key), ); this.inlineTexts = new Map(); + // Every free input shows what it carries, its default too: an untouched `And` reads "false", + // not nothing, empty text reads "(empty)" – the same value the node computes with. A free + // object or struct has nothing to type in and carries null, which it says as well. for (const port of inlineInputs(this.def)) { - const value = inlineValue(node, port); - if (!this.connected.has(port.key) && value !== null && value !== '') - this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project)); + if (this.connected.has(port.key)) continue; + const text = formatField(inlineInputDef(port), inlineValue(node, port), project); + this.inlineTexts.set(port.key, text === '' ? EMPTY_TEXT : text); + } + for (const port of this.def.inputs) { + if ( + (port.type === 'object' || port.type === 'struct') && + edgesInto(graph, node.id, port.key).length === 0 + ) + this.inlineTexts.set(port.key, NULL_TEXT); } this.linked = { inputs: graph.edges.some((e) => e.to.node === node.id), @@ -489,9 +499,13 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s return referenceLabel(def, project, asString(value)) ?? formatValue(value); } +/** How a node says "no value" and "empty value" – for inputs and live outputs alike. */ +const NULL_TEXT = '(null)'; +const EMPTY_TEXT = '(empty)'; + function formatLive(output: PortDef, value: Value, project: Project | null): string { - if (value === null) return '—'; - if (value === '') return '""'; + if (value === null) return NULL_TEXT; + if (value === '') return EMPTY_TEXT; return output.field ? formatField(output.field, value, project) : formatValue(value); } @@ -500,7 +514,7 @@ function formatValue(value: Value): string { if (typeof value === 'boolean') return value ? 'true' : 'false'; // A list can hold hundreds of items – shown whole it would stretch the node across the graph. if (isList(value)) { - if (value.length === 0) return 'empty'; + if (value.length === 0) return EMPTY_TEXT; const head = value.slice(0, 4).map(formatValue).join(', '); return value.length > 4 ? `${head}, … (${value.length})` : head; } diff --git a/tests/core/inline-defaults.test.ts b/tests/core/inline-defaults.test.ts new file mode 100644 index 00000000..8f3791e9 --- /dev/null +++ b/tests/core/inline-defaults.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { + GraphInterpreter, + inlineInputDef, + inlineInputs, + inlineValue, + type PortDef, +} from '../../src/core/graph'; +import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; + +const untouched = { params: {} }; + +/** What a flow logs when `build` hangs a value in front of `To text` → `Log`. */ +function logged( + build: ( + edit: ReturnType, + ) => [ReturnType['addNode']>, string], +) { + const fixture = makeProject(); + const behavior = makeBehavior(fixture, 'B', []); + const edit = graphEditor(fixture, behavior); + const start = edit.addNode('OnStart'); + const log = edit.addNode('Log'); + const text = edit.addNode('ToText'); + const [source, port] = build(edit); + edit.wire([ + [start, 'exec', log, 'exec'], + [text, 'text', log, 'message'], + ...(source === text ? [] : ([[source, port, text, 'value']] as const)), + ]); + const ctx = fakeContext(); + new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); + return ctx.logs; +} + +describe('the value of an unconnected input', () => { + it('is, on every built-in node, the default the inspector shows – never null', () => { + const wrong: string[] = []; + for (const def of BUILT_IN_NODES) { + for (const port of inlineInputs(def)) { + const value = inlineValue(untouched, port); + const shown = inlineInputDef(port).default; + if (value === null || JSON.stringify(value) !== JSON.stringify(shown)) + wrong.push(`${def.type}.${port.key}: ${JSON.stringify(value)} vs ${JSON.stringify(shown)}`); + } + } + expect(wrong).toEqual([]); + }); + + it('is the zero of its kind: false, 0, "" and an empty list', () => { + const port = (type: PortDef['type']): PortDef => ({ key: 'p', label: 'p', type }); + expect( + ['boolean', 'number', 'string', 'any', 'list'].map((t) => + inlineValue(untouched, port(t as PortDef['type'])), + ), + ).toEqual([false, 0, '', '', []]); + }); + + it('stays null where nothing can be typed in: an object is a live handle', () => { + expect(inlineValue(untouched, { key: 'o', label: 'o', type: 'object' })).toBeNull(); + expect(inlineValue(untouched, { key: 'n', label: 'n', type: 'number', inline: false })).toBeNull(); + }); + + it('takes the node’s own value first, then the port default', () => { + const port: PortDef = { key: 'n', label: 'n', type: 'number', default: 5 }; + expect(inlineValue(untouched, port)).toBe(5); + expect(inlineValue({ params: { n: 7 } }, port)).toBe(7); + }); + + it('hands out a fresh empty list each time, so editing one in place touches no other', () => { + const port: PortDef = { key: 'l', label: 'l', type: 'list' }; + const first = inlineValue(untouched, port) as unknown[]; + first.push(1); + expect(inlineValue(untouched, port)).toEqual([]); + }); +}); + +describe('what a flow computes from an untouched node', () => { + it('turns empty text into "", not "0"', () => { + expect(logged((edit) => [edit.addNode('ToText'), 'text'])).toEqual(['']); + }); + + it('lets Select and First not empty pass on "" instead of nothing', () => { + expect(logged((edit) => [edit.addNode('Select'), 'value'])).toEqual(['']); + expect(logged((edit) => [edit.addNode('FirstNotEmpty'), 'value'])).toEqual(['']); + }); + + it('still reads a typed-in number as that number', () => { + expect(logged((edit) => [edit.addNode('Number', { value: 3 }), 'value'])).toEqual(['3']); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index e456beb1..5439935d 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -946,6 +946,7 @@ export async function graph(t) { { id: 'p-text', type: 'String', x: 0, y: 300, params: { value: 'hi' } }, { id: 'p-pick', type: 'FirstNotEmpty', x: 300, y: 60, params: {} }, { id: 'p-sub', type: 'Substring', x: 600, y: 60, params: {} }, + { id: 'p-name', type: 'NameOf', x: 600, y: 300, params: {} }, ], edges: [], }, @@ -959,6 +960,20 @@ export async function graph(t) { (await pickTypes()).outputs.value === 'any', JSON.stringify(await pickTypes()), ); + // An untouched input shows the value the node computes with: empty text reads "(empty)", a + // number 0, and an object with nothing plugged in "(null)". + const untouched = { + pick: await json(`__gm.graph.nodeInputs('p-pick')`), + sub: await json(`__gm.graph.nodeInputs('p-sub')`), + name: await json(`__gm.graph.nodeInputs('p-name')`), + }; + check( + 'untouched inputs show their default beside the name: (empty), 0 and (null) for an object', + JSON.stringify(untouched.pick) === JSON.stringify(['a (empty)', 'b (empty)']) && + JSON.stringify(untouched.sub) === JSON.stringify(['text (empty)', 'start 0', 'count -1']) && + JSON.stringify(untouched.name) === JSON.stringify(['object (null)']), + JSON.stringify(untouched), + ); const wire = async (fromNode, fromPort, toNode, toPort) => { const from = await json(