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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 18:33:06 +02:00
1 parent a40e629fde
commit 2aa635f453
7 files changed
+105 -24

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2
View File
@@ -20,6 +20,8 @@ export function graphHooks({ state, editor }: HookContext) {
nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId), nodeInputs: (nodeId: string) => graphEditor.canvas.nodeInputs(nodeId),
/** Each output row as drawn: while playing, the value it last carried in front of its name. */ /** Each output row as drawn: while playing, the value it last carried in front of its name. */
nodeOutputs: (nodeId: string) => graphEditor.canvas.nodeOutputs(nodeId), 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 in flight on exec edges as drawn, in graph coordinates; `progress` 0 = source, 1 = target. */
beads: () => graphEditor.canvas.beadsDrawn(), beads: () => graphEditor.canvas.beadsDrawn(),
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
+4
View File
@@ -1140,6 +1140,10 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.outputLines ?? null; 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. */ /** Global screen position of a node's title bar centre. */
nodeGlobal(nodeId: string): { x: number; y: number } | null { nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId); const view = this.views.get(nodeId);
+35 -23
View File
@@ -32,6 +32,7 @@ import {
} from '../../ui'; } from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory'; import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding'; import { collapsedNodeWidth } from './folding';
import { inputTextRoom, valueSpan } from './portRow';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */ /** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
export const NODE_WIDTH = 176; export const NODE_WIDTH = 176;
@@ -100,6 +101,12 @@ function rowStyle(port: PortDef): TextStyleToken {
return port.type === 'exec' ? 'body' : 'muted'; return port.type === 'exec' ? 'body' : 'muted';
} }
export interface TextSpan {
text: string;
left: number;
right: number;
}
export interface PortHit { export interface PortHit {
port: PortDef; port: PortDef;
isOutput: boolean; isOutput: boolean;
@@ -110,6 +117,8 @@ export class NodeView extends Widget {
private readonly box = new Graphics(); private readonly box = new Graphics();
private readonly ports = new Graphics(); private readonly ports = new Graphics();
private readonly texts: Text[] = []; 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[] = []; private paramLines: string[] = [];
/** Why this node can do nothing; see `callProblem`. */ /** Why this node can do nothing; see `callProblem`. */
private problem: string | null = null; private problem: string | null = null;
@@ -312,6 +321,12 @@ export class NodeView extends Widget {
return this.def.inputs.map((input) => this.inputText(input)); 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[] { get outputLines(): string[] {
return this.def.outputs.map((output) => { return this.def.outputs.map((output) => {
const value = this.valueTexts.get(output.key)?.text; const value = this.valueTexts.get(output.key)?.text;
@@ -395,6 +410,7 @@ export class NodeView extends Widget {
private rebuildTexts(): void { private rebuildTexts(): void {
for (const t of this.texts) t.destroy(); for (const t of this.texts) t.destroy();
this.texts.length = 0; this.texts.length = 0;
this.rowTexts.length = 0;
this.valueSlots = new Map(); this.valueSlots = new Map();
const add = ( const add = (
text: string, text: string,
@@ -431,33 +447,29 @@ export class NodeView extends Widget {
add(this.problem, 'warning', PAD + 2, y + 2); add(this.problem, 'warning', PAD + 2, y + 2);
y += ROW_HEIGHT; y += ROW_HEIGHT;
} }
const inputX = PAD + PORT_RADIUS + 4;
for (let i = 0; i < this.rowCount(); i++) { for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i]; const input = this.def.inputs[i];
const output = this.def.outputs[i]; const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2; const rowTop = y + i * ROW_HEIGHT + 2;
let inputEnd = PAD; const outputText = output
if (input) { ? add(
const text = add( output.label,
this.inputText(input), rowStyle(output),
rowStyle(input), this.w - PAD - PORT_RADIUS - 4,
PAD + PORT_RADIUS + 4, rowTop,
rowTop, 'right',
'left', NODE_WIDTH / 2 - 10,
NODE_WIDTH / 2 + 20, )
); : null;
inputEnd = text.x + text.width; const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
} const inputText = input
if (output) { ? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
const text = add( : null;
output.label, this.rowTexts.push({ input: inputText, output: outputText });
rowStyle(output), if (output && outputText && output.type !== 'exec' && !output.ghost) {
this.w - PAD - PORT_RADIUS - 4, const inputEnd = inputText ? inputText.x + inputText.width : PAD;
rowTop, this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
'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 });
} }
} }
this.placeValues(); this.placeValues();
+18
View File
@@ -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 };
}
+31
View File
@@ -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);
});
});
+14
View File
@@ -1862,6 +1862,20 @@ export async function graph(t) {
JSON.stringify(logged) === JSON.stringify(['true', 'false']), JSON.stringify(logged) === JSON.stringify(['true', 'false']),
JSON.stringify(logged), 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(` await mutate(`
const p = __gm.state.project; const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing'); p.assets = p.assets.filter((a) => a.id !== 'apy' && a.id !== 'ap-swing');