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
@@ -54,7 +54,9 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
return control && { ...centreOf(control), left: control.getGlobalPosition().x };
|
||||
};
|
||||
/** The control right of the row label `label`, as `fieldOf` finds it. */
|
||||
const controlOf = (label: string, last: boolean): Widget | null => {
|
||||
const controlOf = (label: string, last: boolean): Widget | null => besideOf(label, last, 1);
|
||||
/** The widget `step` places along from the row label `label`: +1 its control, -1 its mark. */
|
||||
const besideOf = (label: string, last: boolean, step: number): Widget | null => {
|
||||
let found: Text | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if ((found !== null && !last) || !node.visible) return;
|
||||
@@ -70,7 +72,7 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
while (owner && !(owner instanceof Widget)) owner = owner.parent;
|
||||
const row = owner?.parent;
|
||||
if (!row || !owner) return null;
|
||||
const control = row.children[row.children.indexOf(owner) + 1];
|
||||
const control = row.children[row.children.indexOf(owner) + step];
|
||||
if (!(control instanceof Widget)) return null;
|
||||
// Taller than the window since its groups have bands (#290): the row is scrolled into view.
|
||||
inspector.reveal(control);
|
||||
@@ -164,6 +166,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||
},
|
||||
inspectorField: fieldOf,
|
||||
/** The mark in front of the row named `label` – what a variable, a field or a port is dragged by (#310). */
|
||||
inspectorMark: (label: string, last = false) => {
|
||||
const mark = besideOf(label, last, -1);
|
||||
return mark && centreOf(mark);
|
||||
},
|
||||
/** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */
|
||||
inspectorEyedropper: (label: string, last = false) => {
|
||||
const button = controlOf(label, last)?.children.at(-1);
|
||||
|
||||
@@ -12,6 +12,8 @@ import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
MoveStructFieldCommand,
|
||||
MoveVariableCommand,
|
||||
RemoveStructFieldCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
@@ -51,7 +53,7 @@ import {
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, Label, Menu, type MenuEntry } from '../../ui';
|
||||
import { Button, Dropdown, Label, Menu, type MenuEntry, type SortBlock, sortBlocks } from '../../ui';
|
||||
import { FontPreview } from '../font/FontPreview';
|
||||
import { clearSaved, hasSaved } from '../saves';
|
||||
import { SoundPreview } from '../sound/SoundPreview';
|
||||
@@ -59,7 +61,7 @@ import type { VariablesOwner } from '../state';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
|
||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
|
||||
import { addedRows, addNameField, addNote, addTitle, type InspectorHost, sectionHeader } from './host';
|
||||
import { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
|
||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||
@@ -289,6 +291,7 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
||||
const values = shownValues(host, asset);
|
||||
const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined =>
|
||||
isType ? { structType: asset.id, path, from, to } : undefined;
|
||||
const blocks: SortBlock[] = [];
|
||||
for (const def of asset.variables) {
|
||||
const edit: VariableEditing = {
|
||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next), renamed([], def.key, next)),
|
||||
@@ -308,6 +311,7 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
||||
path,
|
||||
),
|
||||
),
|
||||
moveField: (path, from, to) => run(new MoveStructFieldCommand(asset.id, def.key, from, to, path)),
|
||||
};
|
||||
const behavior = asset.type === 'behavior';
|
||||
const keep = behavior
|
||||
@@ -324,11 +328,13 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
||||
: undefined;
|
||||
const value = values[def.key] ?? def.default;
|
||||
const typing = structTyping(host, asset, def.key);
|
||||
host.fields.set(
|
||||
def.key,
|
||||
const { rows, built } = addedRows(host, () =>
|
||||
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep, hidden }),
|
||||
);
|
||||
host.fields.set(def.key, built);
|
||||
blocks.push({ grip: built.grip, rows });
|
||||
}
|
||||
sortBlocks(blocks, (from, to) => run(new MoveVariableCommand(asset.id, from, to)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type TextFieldOptions,
|
||||
Theme,
|
||||
type VStack,
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { foldKey, isFolded, setFolded } from '../sectionFolds';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -70,6 +71,14 @@ export function addNote(
|
||||
host.body.add(new Label(text, { style }));
|
||||
}
|
||||
|
||||
/** The rows `build` adds to the body – what one block of a sortable group covers (#310). */
|
||||
export function addedRows<T>(host: InspectorHost, build: () => T): { rows: Widget[]; built: T } {
|
||||
const before = host.body.children.length;
|
||||
const built = build();
|
||||
const rows = host.body.children.slice(before).filter((child): child is Widget => child instanceof Widget);
|
||||
return { rows, built };
|
||||
}
|
||||
|
||||
export function addGap(host: InspectorHost): void {
|
||||
host.body.add(new Label(''));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AddPortCommand,
|
||||
AddPortsCommand,
|
||||
MovePortCommand,
|
||||
nextPortName,
|
||||
RemovePortCommand,
|
||||
RenamePortCommand,
|
||||
@@ -41,6 +42,8 @@ import {
|
||||
Menu,
|
||||
type MenuItem,
|
||||
type SectionHeader,
|
||||
type SortBlock,
|
||||
sortBlocks,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
@@ -261,7 +264,10 @@ export function buildPortSection(
|
||||
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
|
||||
);
|
||||
|
||||
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
|
||||
const blocks = ports.map((port) =>
|
||||
portRow(host, assetId, node, port, portNameField(host, assetId, node, port)),
|
||||
);
|
||||
sortBlocks(blocks, (from, to) => host.state.execute(new MovePortCommand(assetId, node.id, from, to)));
|
||||
}
|
||||
|
||||
const plural = (noun: string) => `${noun.toLowerCase()}s`;
|
||||
@@ -286,15 +292,17 @@ function portRow(
|
||||
port: DeclaredPort,
|
||||
name: Widget,
|
||||
extras: readonly Widget[] = [],
|
||||
): void {
|
||||
): SortBlock {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(markLabel(port.kind));
|
||||
const mark = markLabel(port.kind);
|
||||
row.add(mark);
|
||||
row.add(name, 1);
|
||||
for (const extra of extras) row.add(extra);
|
||||
row.add(new Spacer());
|
||||
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
||||
host.body.add(row);
|
||||
return { grip: mark, rows: [row] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
ScrollContainer,
|
||||
type SortBlock,
|
||||
sortBlocks,
|
||||
Spacer,
|
||||
TextField,
|
||||
Theme,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
||||
import { nextFieldName } from '../../core/commands/naming';
|
||||
import { moveItem } from '../../core/util/list';
|
||||
import { type FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||
import {
|
||||
type ListItem,
|
||||
@@ -312,7 +315,8 @@ export class InlineList extends VStack {
|
||||
this.more = null;
|
||||
if (access) {
|
||||
const { shown, more } = shownItems(access.value.length, this.options.limit);
|
||||
access.value.slice(0, shown).forEach((item, index) => this.itemRow(access, item, index));
|
||||
const blocks = access.value.slice(0, shown).map((item, index) => this.itemRow(access, item, index));
|
||||
sortBlocks(blocks, (from, to) => this.moveItem(from, to));
|
||||
if (more > 0) this.add(this.moreLine(access, more));
|
||||
}
|
||||
this.relayout();
|
||||
@@ -332,7 +336,16 @@ export class InlineList extends VStack {
|
||||
return line;
|
||||
}
|
||||
|
||||
private itemRow(access: ListAccess, item: ListValue[number], index: number): void {
|
||||
/** An item dragged by its number to another place (#310): the order is part of the value. */
|
||||
private moveItem(from: number, to: number): void {
|
||||
const access = this.access();
|
||||
if (!access) return;
|
||||
const next = [...access.value];
|
||||
if (moveItem(next, from, to)) access.replace(next);
|
||||
}
|
||||
|
||||
/** The item's rows, its number being where it is dragged from. */
|
||||
private itemRow(access: ListAccess, item: ListValue[number], index: number): SortBlock {
|
||||
const kind = itemKindOf(item);
|
||||
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
|
||||
const tall = kind === 'struct';
|
||||
@@ -365,7 +378,10 @@ export class InlineList extends VStack {
|
||||
const widgets = this.rowWidgets[index];
|
||||
if (widgets) widgets.remove = remove;
|
||||
this.add(row);
|
||||
if (below) this.add(indented(below, INDEX_WIDTH + Theme.gap));
|
||||
if (!below) return { grip: label, rows: [row] };
|
||||
const items = indented(below, INDEX_WIDTH + Theme.gap);
|
||||
this.add(items);
|
||||
return { grip: label, rows: [row, items] };
|
||||
}
|
||||
|
||||
/** A list inside this one: its head goes where a value would, its items below, one step further in. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { TileAsset } from '../../core/model/Asset';
|
||||
import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||
import {
|
||||
Button,
|
||||
dropIndex,
|
||||
type DropSpot,
|
||||
HStack,
|
||||
Label,
|
||||
@@ -22,24 +23,6 @@ import type { EditorState } from '../state';
|
||||
|
||||
const WIDTH = 210;
|
||||
|
||||
/**
|
||||
* Where a dragged row lands, counted in the list it has *left* – which is what the move command
|
||||
* takes. A drop on the middle of a row takes that row's place: the list is flat, so there is no
|
||||
* "inside" for it to mean, and without this a drag downwards onto the next row would cancel itself
|
||||
* out. The gaps above and below a row are insert points, and one below the dragged row shifts up
|
||||
* by one once the row is out. `-1` is "nothing to do".
|
||||
*/
|
||||
export function dropIndex(from: number, spot: DropSpot, length: number): number {
|
||||
const last = length - 1;
|
||||
if (from < 0 || from > last) return -1;
|
||||
if (spot.at === 'end') return last;
|
||||
const target = Number(spot.targetId);
|
||||
if (!Number.isInteger(target) || target < 0 || target > last) return -1;
|
||||
if (spot.at === 'onto') return target;
|
||||
const insert = target + (spot.at === 'after' ? 1 : 0);
|
||||
return Math.min(last, insert > from ? insert - 1 : insert);
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture list beside the board (#118): one row per animation step, in the order they run.
|
||||
* Rows are addressed by their place rather than by the picture they name – the same block may be
|
||||
|
||||
Reference in new issue
Block a user