From a5184b33685fdb22c3e8e9b77f2658a17777e00a Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Mon, 5 Oct 2026 16:08:59 +0200 Subject: [PATCH] Let the rows of a Variables node be dragged into a new order The Variables and Properties nodes listed their picked rows in the inspector, but unlike Start, End and struct rows they could not be reordered. The drag dots also sat right on the edge of the row and touched the kind mark. - buildPickedPortSection gathers each row with its warning line as one block and hands the blocks to sortBlocks. A move reorders the ports on the node and is one undo step. Properties uses the same section and gains the same reordering. - Grip draws the dots 3 px in from the left edge and moves the kind mark 3 px clear of them (DOTS_PAD). The column keeps its width, so the name fields do not shift. Rows without dots are unchanged. Tests: - block-drag, reorder, variable-choices: 3 files, 13 tests, all green. block-drag now expects the mark 11 px in with dots, 0 without. - e2e 23 on melektaus-atx: ALL PASSED, 32 checks (+3 for dragging vy before vx and undoing it). It ran before the padding change, which touches no checks there. - The gate did not run before this commit. Co-Authored-By: Claude Opus 5.5 --- src/editor/inspector/portSection.ts | 28 ++++++++++++++++++++-------- src/ui/widgets/Grip.ts | 12 ++++++++++-- tests/ui/block-drag.test.ts | 7 +++++++ tools/e2e/23-variable-nodes.mjs | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index c1607b37..6782f285 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -53,7 +53,7 @@ import { type Widget, } from '../../ui'; import { NAME_WIDTH, removeButton } from './fieldFactory'; -import { addNote, type InspectorHost, sectionHeader } from './host'; +import { addedRows, addNote, type InspectorHost, sectionHeader } from './host'; import { kindMenu, markLabel } from './variableRows'; /** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */ @@ -188,17 +188,15 @@ function buildPickedPortSection( if (!custom && choices.length === 0 && empty) addNote(host, empty); else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`); - for (const port of ports) { + const pickedRow = (port: DeclaredPort): Grip => { // A machine a variable holds is a running value; which one its states come from is picked here. const carries = noun === 'Variable' && port.kind === 'statemachine'; const toggle = [ ...(carries ? [machinePick(host, assetId, node, port)] : []), ...writeToggle(host, assetId, node, port), ]; - if (custom) { - portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle); - continue; - } + if (custom) + return portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle).grip; const def = choices.find((d) => d.key === port.key); const known = def !== undefined && isPortOf(port, def); const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' }); @@ -208,9 +206,23 @@ function buildPickedPortSection( ); new Menu(items, { searchable }).openBelow(choose); }; - portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle); + const row = portRow( + host, + assetId, + node, + def && known ? { ...port, kind: portKindOf(def) } : port, + choose, + toggle, + ); if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning'); - } + return row.grip; + }; + // The order of the ports is the order on the node; a warning goes along with the row it is about. + const blocks = ports.map((port) => { + const { rows, built } = addedRows(host, () => pickedRow(port)); + return { grip: built, rows }; + }); + sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to))); } /** diff --git a/src/ui/widgets/Grip.ts b/src/ui/widgets/Grip.ts index 547c3679..27e8a9d1 100644 --- a/src/ui/widgets/Grip.ts +++ b/src/ui/widgets/Grip.ts @@ -6,6 +6,10 @@ import { type Size, Widget } from '../Widget'; const DOT = 2; const DOT_GAP = 1; const IDLE_ALPHA = 0.5; +/** The air on either side of the dots, so they do not touch the edge of the lit block or the mark. */ +const DOTS_PAD = 3; +/** Where the mark starts once the dots are drawn: their two columns and the air around them. */ +const DOTS_INSET = DOTS_PAD + 2 * DOT + DOT_GAP + DOTS_PAD; /** * What a row is taken hold of by to drag it to another place (#310): its mark, the same size. Once @@ -30,12 +34,16 @@ export class Grip extends Widget { } override layout(): void { - this.content.resize(this.w, this.h); + // The dots take the left edge, the mark the rest: it moves over instead of sitting on them. + const inset = this.grabbable ? DOTS_INSET : 0; + this.content.position.x = inset; + this.content.resize(Math.max(0, this.w - inset), this.h); } setGrabbable(on: boolean): void { if (this.grabbable === on) return; this.grabbable = on; + this.layout(); this.invalidate(); } @@ -56,7 +64,7 @@ export class Grip extends Widget { const top = Math.round(this.h / 2 - (3 * DOT + 2 * DOT_GAP) / 2); for (let col = 0; col < 2; col++) for (let row = 0; row < 3; row++) - this.dots.rect(col * (DOT + DOT_GAP), top + row * (DOT + DOT_GAP), DOT, DOT); + this.dots.rect(DOTS_PAD + col * (DOT + DOT_GAP), top + row * (DOT + DOT_GAP), DOT, DOT); this.dots.fill(this.lit ? Theme.colors.text : Theme.colors.textMuted); this.dots.alpha = this.lit ? 1 : IDLE_ALPHA; } diff --git a/tests/ui/block-drag.test.ts b/tests/ui/block-drag.test.ts index 01ed20ec..38cc380a 100644 --- a/tests/ui/block-drag.test.ts +++ b/tests/ui/block-drag.test.ts @@ -44,6 +44,13 @@ describe('the grips of a sortable column', () => { expect(column(false).blocks.map((b) => b.grip.dotsShown)).toEqual(['none', 'none', 'none']); }); + it('move their mark right of the dots, and leave it at the edge where there are none', () => { + const [sorted] = column().blocks; + expect(sorted!.grip.content.x).toBe(11); + expect(sorted!.grip.content.x + sorted!.grip.content.w).toBe(sorted!.grip.w); + expect(column(false).blocks[0]!.grip.content.x).toBe(0); + }); + it('light up under the pointer and back the whole block, behind its rows', () => { const { body, blocks } = column(); const grip = blocks[1]!.grip; diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index c756edd1..21c4eb02 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -156,6 +156,24 @@ export async function variableNodes(t) { await undo(); check('and undo points it back', (await portsOf('vp-get'))[0].key === 'vx'); + // 23e-ii. The ports are reordered by dragging their mark, as on a start node: the order is the + // node's. A drop just above a mark lands in front of its row. + const grabbed = await json(`__gm.inspectorMark('vy', true)`); + const below = await json(`__gm.inspectorMark('vx', true)`); + check( + 'a variable row shows the dots of a mark that drags', + grabbed?.dots === 'idle', + JSON.stringify(grabbed), + ); + await t.drag(grabbed.x, grabbed.y, below.x, below.y - 6); + check( + 'dragging a variable by its mark puts it in front of the other', + (await portsOf('vp-get')).map((p) => p.key).join(',') === 'vy,vx', + JSON.stringify(await portsOf('vp-get')), + ); + await undo(); + check('and undo puts it back', (await portsOf('vp-get')).map((p) => p.key).join(',') === 'vx,vy'); + // 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) => {