Let variables, fields, ports and list items be dragged into order (#310)
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 19m34s

Variables of a behavior, the fields of a struct, the ports of a start
or end node and the items of a list could only be added and removed.
Changing their order meant removing and adding them again. Now each of
them is dragged by the mark in front of its row.

- sortBlocks (ui/blockDrag.ts) makes the rows of one column sortable.
  A block is every row it covers, so a struct moves with its fields and
  a list item with its nested list. The block is dimmed while it is
  carried, an accent line shows where it lands, and a scrolled panel
  follows the pointer at its edge. Less than 4 px stays a click.
- The arithmetic is pure and lives in ui/sortable.ts: blockGap,
  gapLineY, dropSlot and edgeScroll. dropIndex of the tile strip moved
  there too, so the reorder maths sits in one file.
- MoveVariableCommand, MoveStructFieldCommand and MovePortCommand
  build on the existing MoveCommand. Undo moves back.
- Lists need no command. InlineList replaces the list as a whole, as
  it does for every other edit of its items.
- The grip is the kind mark, for a list item its number. Moving only
  happens among siblings: a field stays in its struct.
- On a Struct asset the move runs through WithStructTypes. Typed
  variables, Pack/Expand ports and split outputs follow the new order.
- Values and edges hang on names, so only the order changes. Nodes
  running a behavior read their ports from the start node, the new
  order reaches them without a copy.
- Not sortable by design: the variables of a placed object, the fields
  of a typed struct (moved on the Struct asset) and the picked ports
  of Variables/Properties nodes.
- Test hook __gm.inspectorMark(label) finds the mark of a row.

Tests:
- Gate on melektaus-atx: 235 files, 3572 tests, CHECK EXIT 0 (+9).
- tests/core/reorder.test.ts: a variable, a field two levels deep, a
  Struct asset reaching a typed variable and Pack, and start ports
  reaching the calling node with edges and result kept.
- e2e 22: ALL PASSED, 39 checks (+4, sum 1739). 22f-iii drags a
  variable past a struct of several rows, then a field inside it.
- Counter-probes: dropSlot by top edge instead of middle and a field
  move that ignores its path fail 3 unit tests. With sortBlocks
  switched off, the three order checks of 22f-iii fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 21:45:04 +02:00
1 parent cf6043a066
commit 2c19c89878
18 files changed
+671 -83

No files matched your search

+67 -44
View File
@@ -10,6 +10,8 @@ import {
Menu,
type MenuEntry,
type MenuItem,
type SortBlock,
sortBlocks,
Spacer,
TextField,
Theme,
@@ -33,7 +35,7 @@ import {
structFieldValue,
} from './fieldFactory';
import { KIND_ICON_SVG, VARIABLE_SWITCH_ICON_SVG } from '../panels/markIcons';
import { addNote, type InspectorHost } from './host';
import { addedRows, addNote, type InspectorHost } from './host';
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K) => void): MenuItem[] {
@@ -50,6 +52,8 @@ export interface VariableEditing {
renameField: (path: readonly string[], field: StructFieldDef, next: string) => void;
removeField: (path: readonly string[], field: StructFieldDef) => void;
addField: (path: readonly string[], kind: NestedKind) => void;
/** A field dragged to another place among the fields of its struct (#310). */
moveField: (path: readonly string[], from: number, to: number) => void;
}
/**
@@ -82,6 +86,11 @@ export interface VariableOptions {
actions?: RowActions;
}
/** A variable's first row, and its mark – where the asset lets it be dragged to another place (#310). */
export interface VariableRow extends FieldRow {
grip: Widget;
}
/**
* One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the
* shape belongs to the asset. Adds its rows to the body itself and returns the first.
@@ -92,10 +101,11 @@ export function buildVariable(
value: PropertyValue,
handlers: FieldHandlers,
options: VariableOptions = {},
): FieldRow {
): VariableRow {
const { edit, typing, keep, hidden, actions } = options;
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markLabel(def.kind), name);
const grip = markLabel(def.kind);
const head = gridRow(0, grip, name);
let setValue: (next: PropertyValue) => void = () => {};
let below: Widget | undefined;
// A struct with a type has the type's fields: they are changed on the Struct asset, not here.
@@ -138,6 +148,7 @@ export function buildVariable(
return {
row: head,
grip,
setValue: (next) => setValue(next),
setModified: (modified) => {
if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted');
@@ -175,7 +186,8 @@ function lockValue(field: Field, locked: boolean): void {
/**
* One row per field, each with its own value; a field that is a struct again is a head row with its
* fields indented below it (#239). `path` names the struct these fields belong to. Returns the
* setter that pushes a bundle into them.
* setter that pushes a bundle into them. Where the fields are edited, each one – its own fields
* and items included – is dragged by its mark among its siblings (#310).
*/
function structFieldRows(
host: InspectorHost,
@@ -188,58 +200,69 @@ function structFieldRows(
locked: boolean,
): (next: PropertyValue) => void {
const setters = new Map<string, (item: PropertyValue) => void>();
const blocks: SortBlock[] = [];
for (const field of fields) {
const row = fieldRow(field, path, edit);
const { row, mark } = fieldRow(field, path, edit);
const item = structFieldValue(value, field);
if (field.kind === 'struct') {
const inner = [...path, field.key];
const typed = typing?.typeName(field.structType) ?? null;
row.add(new Spacer(), 1);
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
const { rows } = addedRows(host, () => {
if (field.kind === 'struct') {
const inner = [...path, field.key];
const typed = typing?.typeName(field.structType) ?? null;
row.add(new Spacer(), 1);
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row);
if (typing)
host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
const own = typed === null;
setters.set(
field.key,
structFieldRows(
host,
field.fields,
inner,
item,
handlers,
own ? edit : undefined,
own ? typing : undefined,
locked,
),
);
return;
}
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
const sub = createField(host, fieldDef(field), item, own);
setters.set(field.key, addValue(row, sub));
lockValue(sub, locked);
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row);
if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
const own = typed === null;
setters.set(
field.key,
structFieldRows(
host,
field.fields,
inner,
item,
handlers,
own ? edit : undefined,
own ? typing : undefined,
locked,
),
);
continue;
}
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
const sub = createField(host, fieldDef(field), item, own);
setters.set(field.key, addValue(row, sub));
lockValue(sub, locked);
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
host.body.add(row);
if (field.kind === 'list' && typing) {
const inner = [...path, field.key];
host.body.add(
itemsRow(path.length + 2, typing.typeName(field.itemType), () =>
typing.listMenu(inner, field.itemType),
),
);
}
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
if (field.kind === 'list' && typing) {
const inner = [...path, field.key];
host.body.add(
itemsRow(path.length + 2, typing.typeName(field.itemType), () =>
typing.listMenu(inner, field.itemType),
),
);
}
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
});
blocks.push({ grip: mark, rows });
}
if (edit) sortBlocks(blocks, (from, to) => edit.moveField(path, from, to));
return (next) => {
for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field));
};
}
function fieldRow(field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined): HStack {
function fieldRow(
field: StructFieldDef,
path: readonly string[],
edit: VariableEditing | undefined,
): { row: HStack; mark: Widget } {
const depth = path.length + 1;
const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next)));
return gridRow(depth, markLabel(field.kind), name);
const mark = markLabel(field.kind);
return { row: gridRow(depth, mark, name), mark };
}
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */