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>
152 lines
5.6 KiB
TypeScript
152 lines
5.6 KiB
TypeScript
/**
|
||
* One builder draws a variable on its asset (renamable) and on a placed object (names are labels).
|
||
* `NumberField extends TextField`, so the tests go by the name's widget rather than counting by type.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { PropertyDef, StructDef } from '../../src/core/properties/PropertySchema';
|
||
import { HStack, Label, TextField, Theme, VStack, type Widget } from '../../src/ui';
|
||
import { type FieldHandlers, labelledRow } from '../../src/editor/inspector/fieldFactory';
|
||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||
import { buildVariable, type VariableEditing } from '../../src/editor/inspector/variableRows';
|
||
import { EditorState } from '../../src/editor/state';
|
||
|
||
const STRUCT: StructDef = {
|
||
key: 'piece',
|
||
label: 'piece',
|
||
kind: 'struct',
|
||
default: { x: 0, tag: '' },
|
||
fields: [
|
||
{ key: 'x', kind: 'number' },
|
||
{ key: 'tag', kind: 'text' },
|
||
],
|
||
};
|
||
|
||
const VALUE = { x: 30, tag: 'tee' };
|
||
|
||
const PLAIN: PropertyDef[] = [
|
||
{ key: 'speed', label: 'speed', kind: 'number', default: 0 },
|
||
{ key: 'title', label: 'title', kind: 'text', default: '' },
|
||
{ key: 'alive', label: 'alive', kind: 'boolean', default: true },
|
||
{ key: 'cells', label: 'cells', kind: 'list', default: [] },
|
||
{ key: 'a_rather_long_variable_name', label: 'a_rather_long_variable_name', kind: 'number', default: 0 },
|
||
];
|
||
|
||
function makeHost(): InspectorHost {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
return {
|
||
state,
|
||
body: new VStack(),
|
||
fields: new Map(),
|
||
nameField: null,
|
||
project: () => state.project,
|
||
liveVariables: () => null,
|
||
};
|
||
}
|
||
|
||
const noHandlers = (): FieldHandlers => ({ onInput: () => {}, onChange: () => {} });
|
||
const withFields: FieldHandlers = { ...noHandlers(), structFieldHandlers: noHandlers };
|
||
|
||
const EDIT: VariableEditing = {
|
||
rename: () => {},
|
||
remove: () => {},
|
||
renameField: () => {},
|
||
removeField: () => {},
|
||
addField: () => {},
|
||
moveField: () => {},
|
||
};
|
||
|
||
const rows = (host: InspectorHost): Widget[][] =>
|
||
host.body.children.map((row) => ('children' in row ? (row.children as Widget[]) : []));
|
||
|
||
/** Every widget of the body that carries `text` as its own content, name or value. */
|
||
const carrying = (host: InspectorHost, text: string): Widget[] =>
|
||
rows(host)
|
||
.flat()
|
||
.filter((w) => (w instanceof Label && w.text === text) || (w instanceof TextField && w.value === text));
|
||
|
||
const isName = (w: Widget, name: string) =>
|
||
(w instanceof Label && w.text === name) || (w instanceof TextField && w.value === name);
|
||
|
||
/** Where the widget after the name starts – summed from the measured cells before it. */
|
||
function valueX(host: InspectorHost, name: string): number {
|
||
for (const row of host.body.children) {
|
||
if (!(row instanceof HStack)) continue;
|
||
const items = row.items;
|
||
const at = items.findIndex((w) => isName(w, name));
|
||
if (at < 0) continue;
|
||
return items.slice(0, at + 1).reduce((x, w) => x + w.measure().w + row.gap, row.padding);
|
||
}
|
||
throw new Error(`no row named ${name}`);
|
||
}
|
||
|
||
function buildAll(edit?: VariableEditing): InspectorHost {
|
||
const host = makeHost();
|
||
for (const def of PLAIN) buildVariable(host, def, def.default, noHandlers(), { edit });
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit });
|
||
return host;
|
||
}
|
||
|
||
const NAMES = [...PLAIN.map((d) => d.key), 'x', 'tag'];
|
||
|
||
describe('the column grid', () => {
|
||
it('starts every value at the same x on a placed object, whatever the kind or depth', () => {
|
||
const host = buildAll();
|
||
const xs = NAMES.map((name) => valueX(host, name));
|
||
expect(new Set(xs), JSON.stringify(xs)).toEqual(new Set([xs[0]]));
|
||
});
|
||
|
||
it('and at that same x on the asset, where the names are editable fields', () => {
|
||
const placed = valueX(buildAll(), 'speed');
|
||
const host = buildAll(EDIT);
|
||
const xs = NAMES.map((name) => valueX(host, name));
|
||
expect(xs, JSON.stringify(xs)).toEqual(NAMES.map(() => placed));
|
||
});
|
||
|
||
it('which is where a plain labelled row starts its value too', () => {
|
||
const { label } = labelledRow('Enabled');
|
||
expect(label.minWidth + Theme.gap).toBe(valueX(buildAll(), 'speed'));
|
||
});
|
||
});
|
||
|
||
describe('a struct on a placed object', () => {
|
||
it('is a head row plus one row per field', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
|
||
});
|
||
|
||
it('names the bundle and its fields with labels, so they cannot be edited', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
for (const name of ['piece', 'x', 'tag']) {
|
||
const [widget] = carrying(host, name);
|
||
expect(widget, name).toBeInstanceOf(Label);
|
||
}
|
||
});
|
||
|
||
it('still shows the values in editable fields', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields);
|
||
expect(carrying(host, 'tee')[0]).toBeInstanceOf(TextField);
|
||
});
|
||
});
|
||
|
||
describe('the same struct on the asset that declares it', () => {
|
||
it('names the bundle and its fields with text fields, so they can be renamed', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
|
||
for (const name of ['piece', 'x', 'tag']) {
|
||
const [widget] = carrying(host, name);
|
||
expect(widget, name).toBeInstanceOf(TextField);
|
||
}
|
||
});
|
||
|
||
it('keeps the same row count as the placed one', () => {
|
||
const host = makeHost();
|
||
buildVariable(host, STRUCT, VALUE, withFields, { edit: EDIT });
|
||
expect(host.body.children.length).toBe(1 + STRUCT.fields.length);
|
||
});
|
||
});
|