From 2aa635f4535c53fb3e20daf1cceafff4f6c449eb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 18:33:06 +0200 Subject: [PATCH] Keep an output's name readable beside a long inline value (#306) An input and an output in the same port row were cut to fit each on its own. The input text could reach x = 123 and the output label could start at x = 83, so a long inline value ran under the output's name. At Animation playing a long animation name covered "progress". - The output label is placed first and keeps its room. The input text is cut with an ellipsis 6 px before it, as decided in the issue. - The rule is a pure function in src/editor/graph/portRow.ts. A row without an output keeps the input's old limit of 108 px. - The value a run shows beside an output uses the same rule. Its left edge can no longer lie right of its right edge. - New test hook __gm.graph.rowSpans(nodeId) gives each row's input and output text as cut and placed, in node units. - NODE_WIDTH stays as it is, as decided in the issue. Tests: - Gate: 230 files, 3520 tests (6 new in port-row.test.ts). - e2e 6: all passed, 149 checks (148 + 1). - Counter-check: with the rule switched off, the input text reaches x = 121.4 under "progress" at x = 114.1, and the new check fails. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/debug/graphHooks.ts | 2 ++ src/editor/graph/GraphCanvas.ts | 4 +++ src/editor/graph/NodeView.ts | 58 ++++++++++++++++++++------------- src/editor/graph/portRow.ts | 18 ++++++++++ tests/editor/port-row.test.ts | 31 ++++++++++++++++++ tools/e2e/06-graph.mjs | 14 ++++++++ 7 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 src/editor/graph/portRow.ts create mode 100644 tests/editor/port-row.test.ts diff --git a/README.md b/README.md index 7ec182c4..0098f3a7 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1728 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1729 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 510b9b81..e5f267e7 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -20,6 +20,8 @@ export function graphHooks({ state, editor }: HookContext) { nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId), /** Each output row as drawn: while playing, the value it last carried in front of its name. */ nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId), + /** Each row's input and output text as cut and placed, in node units: two that overlap collide (#306). */ + rowSpans: (nodeId: string) => graphEditor.canvas.nodeRowSpans(nodeId), /** Beads in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */ beads: () => graphEditor.canvas.beadsDrawn(), /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 43b7ef3a..0d300cfa 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1140,6 +1140,10 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.outputLines ?? null; } + nodeRowSpans(nodeId: string): NodeView['rowSpans'] | null { + return this.views.get(nodeId)?.rowSpans ?? null; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 33d7c5f9..d70554bf 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -32,6 +32,7 @@ import { } from '../../ui'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; import { collapsedNodeWidth } from './folding'; +import { inputTextRoom, valueSpan } from './portRow'; /** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */ export const NODE_WIDTH = 176; @@ -100,6 +101,12 @@ function rowStyle(port: PortDef): TextStyleToken { return port.type === 'exec' ? 'body' : 'muted'; } +export interface TextSpan { + text: string; + left: number; + right: number; +} + export interface PortHit { port: PortDef; isOutput: boolean; @@ -110,6 +117,8 @@ export class NodeView extends Widget { private readonly box = new Graphics(); private readonly ports = new Graphics(); private readonly texts: Text[] = []; + /** The input and output text of each port row, among `texts`. */ + private readonly rowTexts: { input: Text | null; output: Text | null }[] = []; private paramLines: string[] = []; /** Why this node can do nothing; see `callProblem`. */ private problem: string | null = null; @@ -312,6 +321,12 @@ export class NodeView extends Widget { return this.def.inputs.map((input) => this.inputText(input)); } + /** Where each port row's input and output text really landed, in node units, as cut to fit. */ + get rowSpans(): { input: TextSpan | null; output: TextSpan | null }[] { + const span = (t: Text | null): TextSpan | null => t && { text: t.text, left: t.x, right: t.x + t.width }; + return this.rowTexts.map((row) => ({ input: span(row.input), output: span(row.output) })); + } + get outputLines(): string[] { return this.def.outputs.map((output) => { const value = this.valueTexts.get(output.key)?.text; @@ -395,6 +410,7 @@ export class NodeView extends Widget { private rebuildTexts(): void { for (const t of this.texts) t.destroy(); this.texts.length = 0; + this.rowTexts.length = 0; this.valueSlots = new Map(); const add = ( text: string, @@ -431,33 +447,29 @@ export class NodeView extends Widget { add(this.problem, 'warning', PAD + 2, y + 2); y += ROW_HEIGHT; } + const inputX = PAD + PORT_RADIUS + 4; for (let i = 0; i < this.rowCount(); i++) { const input = this.def.inputs[i]; const output = this.def.outputs[i]; const rowTop = y + i * ROW_HEIGHT + 2; - let inputEnd = PAD; - if (input) { - const text = add( - this.inputText(input), - rowStyle(input), - PAD + PORT_RADIUS + 4, - rowTop, - 'left', - NODE_WIDTH / 2 + 20, - ); - inputEnd = text.x + text.width; - } - if (output) { - const text = add( - output.label, - rowStyle(output), - this.w - PAD - PORT_RADIUS - 4, - rowTop, - 'right', - NODE_WIDTH / 2 - 10, - ); - if (output.type !== 'exec' && !output.ghost) - this.valueSlots.set(output.key, { left: inputEnd + 6, right: text.x - 6, top: rowTop + 1 }); + const outputText = output + ? add( + output.label, + rowStyle(output), + this.w - PAD - PORT_RADIUS - 4, + rowTop, + 'right', + NODE_WIDTH / 2 - 10, + ) + : null; + const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null); + const inputText = input + ? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room) + : null; + this.rowTexts.push({ input: inputText, output: outputText }); + if (output && outputText && output.type !== 'exec' && !output.ghost) { + const inputEnd = inputText ? inputText.x + inputText.width : PAD; + this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 }); } } this.placeValues(); diff --git a/src/editor/graph/portRow.ts b/src/editor/graph/portRow.ts new file mode 100644 index 00000000..a6e30bac --- /dev/null +++ b/src/editor/graph/portRow.ts @@ -0,0 +1,18 @@ +/** Space kept between the texts that share one port row. */ +export const ROW_GAP = 6; + +/** + * How wide the input text of a port row may grow. The output label keeps its room and the input + * gives way: a link is drawn from the output's name, and the input's value is in the inspector too. + * `outputLeft` is null on a row without an output, which leaves the input its own limit. + */ +export function inputTextRoom(inputX: number, ownMax: number, outputLeft: number | null): number { + if (outputLeft === null) return ownMax; + return Math.max(0, Math.min(ownMax, outputLeft - ROW_GAP - inputX)); +} + +/** Where a running game shows an output's value: between the input text and the output label. */ +export function valueSpan(inputEnd: number, outputLeft: number): { left: number; right: number } { + const right = outputLeft - ROW_GAP; + return { left: Math.min(inputEnd + ROW_GAP, right), right }; +} diff --git a/tests/editor/port-row.test.ts b/tests/editor/port-row.test.ts new file mode 100644 index 00000000..bea9b56a --- /dev/null +++ b/tests/editor/port-row.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { inputTextRoom, ROW_GAP, valueSpan } from '../../src/editor/graph/portRow'; + +describe('the input text of a port row', () => { + it('keeps its own limit while the output label leaves room for it', () => { + expect(inputTextRoom(15, 108, 140)).toBe(108); + }); + + it('gives way before the output label, keeping the gap', () => { + expect(inputTextRoom(15, 108, 100)).toBe(100 - ROW_GAP - 15); + }); + + it('keeps its full limit on a row without an output', () => { + expect(inputTextRoom(15, 108, null)).toBe(108); + }); + + it('gets no room at all rather than a negative one', () => { + expect(inputTextRoom(15, 108, 10)).toBe(0); + }); +}); + +describe('the value a run shows beside an output', () => { + it('sits between the input text and the output label', () => { + expect(valueSpan(60, 120)).toEqual({ left: 60 + ROW_GAP, right: 120 - ROW_GAP }); + }); + + it('never starts right of where it has to end', () => { + const span = valueSpan(110, 116); + expect(span.left).toBeLessThanOrEqual(span.right); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index fe20ee82..5d3cd9a0 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1862,6 +1862,20 @@ export async function graph(t) { JSON.stringify(logged) === JSON.stringify(['true', 'false']), JSON.stringify(logged), ); + // #306: a long animation name gives way before the output label of its row. + await mutate( + `__gm.state.project.assets.find((a) => a.id === 'ap-swing').name = 'Swing with a name far too long for its row'`, + ); + await shot('28b-animation-playing-long-name'); + const row = (await json(`__gm.graph.rowSpans(${id})`))?.[1]; + check( + 'a long inline value is cut before the output label of its row, which stays whole', + row?.input?.text.endsWith('…') && + 'animation Swing with a name far too long for its row'.startsWith(row.input.text.slice(0, -1)) && + row.output?.text === 'progress' && + row.input.right + 6 <= row.output.left, + JSON.stringify(row), + ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing');