Let variables, fields, ports and list items be dragged into order (#310)
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:
1 parent
cf6043a066
commit
2c19c89878
18 files changed
+671
-83
No files matched your search
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Reordering what the inspector lets be dragged (#310): variables, the fields of a struct at any
|
||||
* depth, a Struct asset's fields with every place of its type, and the declared ports of a start node.
|
||||
* Only the order may change – values, edges and what runs stay as they were.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { AddPortCommand, MovePortCommand } from '../../src/core/commands/portCommands';
|
||||
import { SetStructTypeCommand, WithStructTypes } from '../../src/core/commands/structTypeCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
MoveStructFieldCommand,
|
||||
MoveVariableCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
BEHAVIOR_END,
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
GraphInterpreter,
|
||||
RUN_BEHAVIOR,
|
||||
resolveDef,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { StructDef } from '../../src/core/properties/PropertySchema';
|
||||
import { fakeContext, fire, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
const keys = (list: readonly { key: string }[] | undefined) => (list ?? []).map((item) => item.key);
|
||||
|
||||
describe('reordering variables and struct fields', () => {
|
||||
it('moves a variable and leaves the values alone; undo moves it back', () => {
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B', ['a', 'b', 'c']);
|
||||
const props = structuredClone(behavior.props);
|
||||
f.stack.execute(new MoveVariableCommand(behavior.id, 0, 2));
|
||||
expect(keys(behavior.variables)).toEqual(['b', 'c', 'a']);
|
||||
expect(behavior.props).toEqual(props);
|
||||
f.stack.undo();
|
||||
expect(keys(behavior.variables)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('refuses a place the list does not have', () => {
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B', ['a', 'b']);
|
||||
expect(() => new MoveVariableCommand(behavior.id, 0, 2).execute(f.project)).toThrow(CommandError);
|
||||
expect(keys(behavior.variables)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('moves a field inside a struct inside a struct, the rest of the declaration as it was', () => {
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'hero', 'struct'));
|
||||
for (const field of ['name', 'pos'])
|
||||
f.stack.execute(
|
||||
new AddStructFieldCommand(
|
||||
behavior.id,
|
||||
'hero',
|
||||
field === 'pos' ? { key: field, kind: 'struct', fields: [] } : { key: field, kind: 'text' },
|
||||
),
|
||||
);
|
||||
for (const field of ['x', 'y', 'z'])
|
||||
f.stack.execute(
|
||||
new AddStructFieldCommand(behavior.id, 'hero', { key: field, kind: 'number' }, ['pos']),
|
||||
);
|
||||
const hero = () => behavior.variables[0] as StructDef;
|
||||
const pos = () => hero().fields.find((field) => field.key === 'pos') as StructDef;
|
||||
const value = structuredClone(behavior.props.hero);
|
||||
|
||||
f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 2, 0, ['pos']));
|
||||
expect(keys(pos().fields)).toEqual(['z', 'x', 'y']);
|
||||
expect(keys(hero().fields)).toEqual(['name', 'pos']);
|
||||
expect(behavior.props.hero).toEqual(value);
|
||||
|
||||
f.stack.execute(new MoveStructFieldCommand(behavior.id, 'hero', 1, 0));
|
||||
expect(keys(hero().fields)).toEqual(['pos', 'name']);
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect(keys(hero().fields)).toEqual(['name', 'pos']);
|
||||
expect(keys(pos().fields)).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
});
|
||||
|
||||
/** `Loot` = { Name: "Coin", Amount: 1 } */
|
||||
function lootType(f: ProjectFixture): StructAsset {
|
||||
const create = new CreateAssetCommand('struct', 'Loot');
|
||||
f.stack.execute(create);
|
||||
const id = create.asset.id;
|
||||
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
|
||||
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
|
||||
f.stack.execute(new SetAssetPropertyCommand(id, 'Amount', 1));
|
||||
return create.asset as StructAsset;
|
||||
}
|
||||
|
||||
describe('reordering the fields of a Struct asset', () => {
|
||||
it('reaches every place of the type: typed variables and the ports of Pack, edges kept', () => {
|
||||
const f = makeProject();
|
||||
const loot = lootType(f);
|
||||
const chest = makeBehavior(f, 'Chest');
|
||||
f.stack.execute(new AddVariableCommand(chest.id, 'drop', 'struct'));
|
||||
f.stack.execute(
|
||||
new SetStructTypeCommand({ kind: 'variable', ownerId: chest.id, key: 'drop', path: [] }, loot.id),
|
||||
);
|
||||
const g = graphEditor(f, chest);
|
||||
const pack = g.addNode('PackStruct');
|
||||
f.stack.execute(new SetStructTypeCommand({ kind: 'node', assetId: chest.id, nodeId: pack.id }, loot.id));
|
||||
const number = g.addNode('Number');
|
||||
g.connect(number, 'value', pack, 'Amount');
|
||||
const drop = () => chest.variables[0] as StructDef;
|
||||
|
||||
f.stack.execute(new WithStructTypes(new MoveVariableCommand(loot.id, 1, 0)));
|
||||
expect(keys(loot.variables)).toEqual(['Amount', 'Name']);
|
||||
expect(keys(drop().fields)).toEqual(['Amount', 'Name']);
|
||||
expect(keys(pack.ports)).toEqual(['Amount', 'Name']);
|
||||
expect(chest.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['value>Amount']);
|
||||
expect(chest.props.drop).toEqual({ Name: '', Amount: 1 });
|
||||
|
||||
f.stack.undo();
|
||||
expect(keys(drop().fields)).toEqual(['Name', 'Amount']);
|
||||
expect(keys(pack.ports)).toEqual(['Name', 'Amount']);
|
||||
});
|
||||
});
|
||||
|
||||
/** Hands `a - b` back: two inputs whose order matters to the arithmetic, not to the wiring. */
|
||||
function subtracter(f: ProjectFixture): BehaviorAsset {
|
||||
const asset = makeBehavior(f, 'Subtract');
|
||||
const edit = graphEditor(f, asset);
|
||||
const start = edit.addNode(BEHAVIOR_START);
|
||||
const end = edit.addNode(BEHAVIOR_END);
|
||||
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'a', kind: 'number' }));
|
||||
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'b', kind: 'number' }));
|
||||
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'out1', kind: 'number' }));
|
||||
const minus = edit.addNode('Subtract');
|
||||
edit.wire([
|
||||
[start, 'exec', end, 'exec'],
|
||||
[start, 'a', minus, 'a'],
|
||||
[start, 'b', minus, 'b'],
|
||||
[minus, 'result', end, 'out1'],
|
||||
]);
|
||||
return asset;
|
||||
}
|
||||
|
||||
describe('reordering the ports of a start node', () => {
|
||||
it('changes the order on the calling node and nothing else: edges, values and result stay', () => {
|
||||
const f = makeProject();
|
||||
const callee = subtracter(f);
|
||||
const caller = makeBehavior(f, 'Caller', ['result']);
|
||||
const edit = graphEditor(f, caller);
|
||||
const onStart = edit.addNode('OnStart');
|
||||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, a: 10, b: 3 });
|
||||
const set = edit.variables(['result'], { write: true });
|
||||
edit.wire([
|
||||
[onStart, 'exec', call, 'exec'],
|
||||
[call, 'exec', set, 'exec'],
|
||||
[call, 'out1', set, 'result'],
|
||||
]);
|
||||
const start = callee.graph.nodes.find((n) => n.type === BEHAVIOR_START)!;
|
||||
const edges = structuredClone(callee.graph.edges);
|
||||
const inputs = () => keys(resolveDef(call, caller.graph, graphLookup(f.project))!.inputs);
|
||||
const run = () => {
|
||||
const ctx = fakeContext({ variables: { result: 0 } });
|
||||
fire(new GraphInterpreter(caller.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||||
return ctx.variables.result;
|
||||
};
|
||||
expect(inputs()).toEqual(['exec', 'a', 'b']);
|
||||
|
||||
f.stack.execute(new MovePortCommand(callee.id, start.id, 1, 0));
|
||||
expect(keys(start.ports)).toEqual(['b', 'a']);
|
||||
expect(inputs()).toEqual(['exec', 'b', 'a']);
|
||||
expect(callee.graph.edges).toEqual(edges);
|
||||
expect(call.params).toMatchObject({ a: 10, b: 3 });
|
||||
expect(run()).toBe(7);
|
||||
|
||||
f.stack.undo();
|
||||
expect(inputs()).toEqual(['exec', 'a', 'b']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user