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
+636
-48
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1735 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1739 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import { isGraphAsset } from '../model/Asset';
|
|||||||
import type { PropertyValue } from '../model/Properties';
|
import type { PropertyValue } from '../model/Properties';
|
||||||
import { kindLabel } from '../properties/kinds';
|
import { kindLabel } from '../properties/kinds';
|
||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||||
import { type Command, CommandError, RenameCommand } from './Command';
|
import { type Command, CommandError, MoveCommand, RenameCommand } from './Command';
|
||||||
import { nextFieldName } from './naming';
|
import { nextFieldName } from './naming';
|
||||||
import { requireGraph, requireNode, requireNodeDef } from './require';
|
import { requireGraph, requireNode, requireNodeDef } from './require';
|
||||||
|
|
||||||
@@ -303,6 +303,29 @@ export class RenamePortCommand extends RenameCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edges hang on a port's name, so they stay connected; a running or calling node takes the order
|
||||||
|
* from here, as it takes the ports themselves (#310).
|
||||||
|
*/
|
||||||
|
export class MovePortCommand extends MoveCommand {
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
super('Reorder ports', from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected move(project: Project, from: number, to: number): void {
|
||||||
|
const node = requireNode(project, this.assetId, this.nodeId);
|
||||||
|
const ports = [...(node.ports ?? [])];
|
||||||
|
if (!moveItem(ports, from, to))
|
||||||
|
throw new CommandError(`There is no port ${from + 1} to move to place ${to + 1}`);
|
||||||
|
node.ports = ports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
|
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
|
||||||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||||||
const ports = node.ports ?? [];
|
const ports = node.ports ?? [];
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
variableDef,
|
variableDef,
|
||||||
} from '../properties/PropertySchema';
|
} from '../properties/PropertySchema';
|
||||||
import { valueAt, withValueAt } from '../properties/valuePath';
|
import { valueAt, withValueAt } from '../properties/valuePath';
|
||||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeFirst, restoreAt } from '../util/list';
|
||||||
import { type Command, CommandError, RenameCommand } from './Command';
|
import { type Command, CommandError, MoveCommand, RenameCommand } from './Command';
|
||||||
import { renameVariablePorts } from './portCommands';
|
import { renameVariablePorts } from './portCommands';
|
||||||
import { requireBehavior, requireVariableOwner } from './require';
|
import { requireBehavior, requireVariableOwner } from './require';
|
||||||
|
|
||||||
@@ -183,6 +183,22 @@ export class RenameVariableCommand extends RenameCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only the order changes: values and the ports naming a variable go by its name (#310). */
|
||||||
|
export class MoveVariableCommand extends MoveCommand {
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
super('Reorder variables', from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected move(project: Project, from: number, to: number): void {
|
||||||
|
if (!moveItem(requireVariableOwner(project, this.assetId).variables, from, to))
|
||||||
|
throw new CommandError(`There is no variable ${from + 1} to move to place ${to + 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The behavior, once it is known to declare `key`. */
|
/** The behavior, once it is known to declare `key`. */
|
||||||
function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset {
|
function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset {
|
||||||
const asset = requireBehavior(project, assetId);
|
const asset = requireBehavior(project, assetId);
|
||||||
@@ -467,3 +483,25 @@ export class RenameStructFieldCommand extends RenameCommand {
|
|||||||
site.write(withFieldRenamed(site.read(), from, to));
|
site.write(withFieldRenamed(site.read(), from, to));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A struct value is read by field name, so only the declaration changes order (#310). */
|
||||||
|
export class MoveStructFieldCommand extends MoveCommand {
|
||||||
|
/** `path` names the struct whose fields are reordered: none for the variable itself. */
|
||||||
|
constructor(
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly key: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
private readonly path: readonly string[] = [],
|
||||||
|
) {
|
||||||
|
super('Reorder fields', from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected move(project: Project, from: number, to: number): void {
|
||||||
|
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||||||
|
const fields = [...owner.fields()];
|
||||||
|
if (!moveItem(fields, from, to))
|
||||||
|
throw new CommandError(`There is no field ${from + 1} to move to place ${to + 1}`);
|
||||||
|
owner.setFields(fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,9 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
return control && { ...centreOf(control), left: control.getGlobalPosition().x };
|
return control && { ...centreOf(control), left: control.getGlobalPosition().x };
|
||||||
};
|
};
|
||||||
/** The control right of the row label `label`, as `fieldOf` finds it. */
|
/** 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;
|
let found: Text | null = null;
|
||||||
const walk = (node: Container): void => {
|
const walk = (node: Container): void => {
|
||||||
if ((found !== null && !last) || !node.visible) return;
|
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;
|
while (owner && !(owner instanceof Widget)) owner = owner.parent;
|
||||||
const row = owner?.parent;
|
const row = owner?.parent;
|
||||||
if (!row || !owner) return null;
|
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;
|
if (!(control instanceof Widget)) return null;
|
||||||
// Taller than the window since its groups have bands (#290): the row is scrolled into view.
|
// Taller than the window since its groups have bands (#290): the row is scrolled into view.
|
||||||
inspector.reveal(control);
|
inspector.reveal(control);
|
||||||
@@ -164,6 +166,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||||
},
|
},
|
||||||
inspectorField: fieldOf,
|
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. */
|
/** The eyedropper beside the object field of a row (#276): where to press it, and whether it is lit. */
|
||||||
inspectorEyedropper: (label: string, last = false) => {
|
inspectorEyedropper: (label: string, last = false) => {
|
||||||
const button = controlOf(label, last)?.children.at(-1);
|
const button = controlOf(label, last)?.children.at(-1);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
|||||||
import {
|
import {
|
||||||
AddStructFieldCommand,
|
AddStructFieldCommand,
|
||||||
AddVariableCommand,
|
AddVariableCommand,
|
||||||
|
MoveStructFieldCommand,
|
||||||
|
MoveVariableCommand,
|
||||||
RemoveStructFieldCommand,
|
RemoveStructFieldCommand,
|
||||||
RemoveVariableCommand,
|
RemoveVariableCommand,
|
||||||
RenameStructFieldCommand,
|
RenameStructFieldCommand,
|
||||||
@@ -51,7 +53,7 @@ import {
|
|||||||
} from '../../core/properties/PropertySchema';
|
} from '../../core/properties/PropertySchema';
|
||||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
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 { FontPreview } from '../font/FontPreview';
|
||||||
import { clearSaved, hasSaved } from '../saves';
|
import { clearSaved, hasSaved } from '../saves';
|
||||||
import { SoundPreview } from '../sound/SoundPreview';
|
import { SoundPreview } from '../sound/SoundPreview';
|
||||||
@@ -59,7 +61,7 @@ import type { VariablesOwner } from '../state';
|
|||||||
import { aspectActions, sizeCommand } from './aspectAction';
|
import { aspectActions, sizeCommand } from './aspectAction';
|
||||||
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
|
import { buildCutRows, CUT_ROW_KEYS, refreshCutRows } from './cutSection';
|
||||||
import { actionIf, createFieldRow, type FieldHandlers } from './fieldFactory';
|
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 { buildShapeListSection, shapeListSignature } from './shapeSection';
|
||||||
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
|
import { buildVariable, kindMenu, type StructTyping, type VariableEditing } from './variableRows';
|
||||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||||
@@ -289,6 +291,7 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
|||||||
const values = shownValues(host, asset);
|
const values = shownValues(host, asset);
|
||||||
const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined =>
|
const renamed = (path: readonly string[], from: string, to: string): FieldRename | undefined =>
|
||||||
isType ? { structType: asset.id, path, from, to } : undefined;
|
isType ? { structType: asset.id, path, from, to } : undefined;
|
||||||
|
const blocks: SortBlock[] = [];
|
||||||
for (const def of asset.variables) {
|
for (const def of asset.variables) {
|
||||||
const edit: VariableEditing = {
|
const edit: VariableEditing = {
|
||||||
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next), renamed([], def.key, next)),
|
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,
|
path,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
moveField: (path, from, to) => run(new MoveStructFieldCommand(asset.id, def.key, from, to, path)),
|
||||||
};
|
};
|
||||||
const behavior = asset.type === 'behavior';
|
const behavior = asset.type === 'behavior';
|
||||||
const keep = behavior
|
const keep = behavior
|
||||||
@@ -324,11 +328,13 @@ export function buildVariables(host: InspectorHost, asset: VariableOwnerAsset, i
|
|||||||
: undefined;
|
: undefined;
|
||||||
const value = values[def.key] ?? def.default;
|
const value = values[def.key] ?? def.default;
|
||||||
const typing = structTyping(host, asset, def.key);
|
const typing = structTyping(host, asset, def.key);
|
||||||
host.fields.set(
|
const { rows, built } = addedRows(host, () =>
|
||||||
def.key,
|
|
||||||
buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit, typing, keep, hidden }),
|
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,
|
type TextFieldOptions,
|
||||||
Theme,
|
Theme,
|
||||||
type VStack,
|
type VStack,
|
||||||
|
Widget,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { foldKey, isFolded, setFolded } from '../sectionFolds';
|
import { foldKey, isFolded, setFolded } from '../sectionFolds';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
@@ -70,6 +71,14 @@ export function addNote(
|
|||||||
host.body.add(new Label(text, { style }));
|
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 {
|
export function addGap(host: InspectorHost): void {
|
||||||
host.body.add(new Label(''));
|
host.body.add(new Label(''));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AddPortCommand,
|
AddPortCommand,
|
||||||
AddPortsCommand,
|
AddPortsCommand,
|
||||||
|
MovePortCommand,
|
||||||
nextPortName,
|
nextPortName,
|
||||||
RemovePortCommand,
|
RemovePortCommand,
|
||||||
RenamePortCommand,
|
RenamePortCommand,
|
||||||
@@ -41,6 +42,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
type MenuItem,
|
type MenuItem,
|
||||||
type SectionHeader,
|
type SectionHeader,
|
||||||
|
type SortBlock,
|
||||||
|
sortBlocks,
|
||||||
Spacer,
|
Spacer,
|
||||||
TextField,
|
TextField,
|
||||||
Theme,
|
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.',
|
: '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`;
|
const plural = (noun: string) => `${noun.toLowerCase()}s`;
|
||||||
@@ -286,15 +292,17 @@ function portRow(
|
|||||||
port: DeclaredPort,
|
port: DeclaredPort,
|
||||||
name: Widget,
|
name: Widget,
|
||||||
extras: readonly Widget[] = [],
|
extras: readonly Widget[] = [],
|
||||||
): void {
|
): SortBlock {
|
||||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
row.minHeight = Theme.rowHeight;
|
row.minHeight = Theme.rowHeight;
|
||||||
row.add(markLabel(port.kind));
|
const mark = markLabel(port.kind);
|
||||||
|
row.add(mark);
|
||||||
row.add(name, 1);
|
row.add(name, 1);
|
||||||
for (const extra of extras) row.add(extra);
|
for (const extra of extras) row.add(extra);
|
||||||
row.add(new Spacer());
|
row.add(new Spacer());
|
||||||
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
||||||
host.body.add(row);
|
host.body.add(row);
|
||||||
|
return { grip: mark, rows: [row] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
type MenuEntry,
|
type MenuEntry,
|
||||||
type MenuItem,
|
type MenuItem,
|
||||||
|
type SortBlock,
|
||||||
|
sortBlocks,
|
||||||
Spacer,
|
Spacer,
|
||||||
TextField,
|
TextField,
|
||||||
Theme,
|
Theme,
|
||||||
@@ -33,7 +35,7 @@ import {
|
|||||||
structFieldValue,
|
structFieldValue,
|
||||||
} from './fieldFactory';
|
} from './fieldFactory';
|
||||||
import { KIND_ICON_SVG, VARIABLE_SWITCH_ICON_SVG } from '../panels/markIcons';
|
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. */
|
/** 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[] {
|
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;
|
renameField: (path: readonly string[], field: StructFieldDef, next: string) => void;
|
||||||
removeField: (path: readonly string[], field: StructFieldDef) => void;
|
removeField: (path: readonly string[], field: StructFieldDef) => void;
|
||||||
addField: (path: readonly string[], kind: NestedKind) => 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;
|
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
|
* 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.
|
* 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,
|
value: PropertyValue,
|
||||||
handlers: FieldHandlers,
|
handlers: FieldHandlers,
|
||||||
options: VariableOptions = {},
|
options: VariableOptions = {},
|
||||||
): FieldRow {
|
): VariableRow {
|
||||||
const { edit, typing, keep, hidden, actions } = options;
|
const { edit, typing, keep, hidden, actions } = options;
|
||||||
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
|
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 setValue: (next: PropertyValue) => void = () => {};
|
||||||
let below: Widget | undefined;
|
let below: Widget | undefined;
|
||||||
// A struct with a type has the type's fields: they are changed on the Struct asset, not here.
|
// 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 {
|
return {
|
||||||
row: head,
|
row: head,
|
||||||
|
grip,
|
||||||
setValue: (next) => setValue(next),
|
setValue: (next) => setValue(next),
|
||||||
setModified: (modified) => {
|
setModified: (modified) => {
|
||||||
if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted');
|
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
|
* 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
|
* 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(
|
function structFieldRows(
|
||||||
host: InspectorHost,
|
host: InspectorHost,
|
||||||
@@ -188,9 +200,11 @@ function structFieldRows(
|
|||||||
locked: boolean,
|
locked: boolean,
|
||||||
): (next: PropertyValue) => void {
|
): (next: PropertyValue) => void {
|
||||||
const setters = new Map<string, (item: PropertyValue) => void>();
|
const setters = new Map<string, (item: PropertyValue) => void>();
|
||||||
|
const blocks: SortBlock[] = [];
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const row = fieldRow(field, path, edit);
|
const { row, mark } = fieldRow(field, path, edit);
|
||||||
const item = structFieldValue(value, field);
|
const item = structFieldValue(value, field);
|
||||||
|
const { rows } = addedRows(host, () => {
|
||||||
if (field.kind === 'struct') {
|
if (field.kind === 'struct') {
|
||||||
const inner = [...path, field.key];
|
const inner = [...path, field.key];
|
||||||
const typed = typing?.typeName(field.structType) ?? null;
|
const typed = typing?.typeName(field.structType) ?? null;
|
||||||
@@ -198,7 +212,8 @@ function structFieldRows(
|
|||||||
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
|
if (edit && typed === null) row.add(addFieldMenu(edit, inner));
|
||||||
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
if (edit) row.add(removeButton(() => edit.removeField(path, field)));
|
||||||
host.body.add(row);
|
host.body.add(row);
|
||||||
if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
|
if (typing)
|
||||||
|
host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType)));
|
||||||
const own = typed === null;
|
const own = typed === null;
|
||||||
setters.set(
|
setters.set(
|
||||||
field.key,
|
field.key,
|
||||||
@@ -213,7 +228,7 @@ function structFieldRows(
|
|||||||
locked,
|
locked,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY;
|
||||||
const sub = createField(host, fieldDef(field), item, own);
|
const sub = createField(host, fieldDef(field), item, own);
|
||||||
@@ -230,16 +245,24 @@ function structFieldRows(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2));
|
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) => {
|
return (next) => {
|
||||||
for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field));
|
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 depth = path.length + 1;
|
||||||
const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next)));
|
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. */
|
/** Which Struct asset a struct follows, as a row of its own under the struct's head. */
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
type MenuEntry,
|
type MenuEntry,
|
||||||
ScrollContainer,
|
ScrollContainer,
|
||||||
|
type SortBlock,
|
||||||
|
sortBlocks,
|
||||||
Spacer,
|
Spacer,
|
||||||
TextField,
|
TextField,
|
||||||
Theme,
|
Theme,
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
import type { Project } from '../../core/model/Project';
|
import type { Project } from '../../core/model/Project';
|
||||||
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
||||||
import { nextFieldName } from '../../core/commands/naming';
|
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 FieldKind, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
type ListItem,
|
type ListItem,
|
||||||
@@ -312,7 +315,8 @@ export class InlineList extends VStack {
|
|||||||
this.more = null;
|
this.more = null;
|
||||||
if (access) {
|
if (access) {
|
||||||
const { shown, more } = shownItems(access.value.length, this.options.limit);
|
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));
|
if (more > 0) this.add(this.moreLine(access, more));
|
||||||
}
|
}
|
||||||
this.relayout();
|
this.relayout();
|
||||||
@@ -332,7 +336,16 @@ export class InlineList extends VStack {
|
|||||||
return line;
|
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);
|
const kind = itemKindOf(item);
|
||||||
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
|
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
|
||||||
const tall = kind === 'struct';
|
const tall = kind === 'struct';
|
||||||
@@ -365,7 +378,10 @@ export class InlineList extends VStack {
|
|||||||
const widgets = this.rowWidgets[index];
|
const widgets = this.rowWidgets[index];
|
||||||
if (widgets) widgets.remove = remove;
|
if (widgets) widgets.remove = remove;
|
||||||
this.add(row);
|
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. */
|
/** 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 { pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
dropIndex,
|
||||||
type DropSpot,
|
type DropSpot,
|
||||||
HStack,
|
HStack,
|
||||||
Label,
|
Label,
|
||||||
@@ -22,24 +23,6 @@ import type { EditorState } from '../state';
|
|||||||
|
|
||||||
const WIDTH = 210;
|
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.
|
* 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
|
* Rows are addressed by their place rather than by the picture they name – the same block may be
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { type Container, type FederatedPointerEvent, Graphics, Point } from 'pixi.js';
|
||||||
|
import { cancelFrameWork, onFrame } from './render';
|
||||||
|
import { blockGap, type BlockSpan, dropSlot, edgeScroll, gapLineY } from './sortable';
|
||||||
|
import { Theme } from './Theme';
|
||||||
|
import type { Widget } from './Widget';
|
||||||
|
import { ScrollContainer } from './widgets/ScrollContainer';
|
||||||
|
|
||||||
|
/** One thing to reorder: where it is taken hold of, and the rows it fills in its column. */
|
||||||
|
export interface SortBlock {
|
||||||
|
grip: Widget;
|
||||||
|
/** Siblings in one column, top to bottom. */
|
||||||
|
rows: readonly Widget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the pointer goes before a press on a grip becomes a drag; less stays a click. */
|
||||||
|
const DRAG_THRESHOLD = 4;
|
||||||
|
const FRAME_KEY = 'block-drag';
|
||||||
|
const DIMMED = 0.4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the blocks of one column be reordered by dragging their grips (#310). Unlike a
|
||||||
|
* `SortableList` the rows are ordinary widgets of any height – a struct is its head with its fields
|
||||||
|
* below – so a block is told by the rows it covers. While dragging, the block is dimmed, a line
|
||||||
|
* shows where it lands, and a scrolled column follows the pointer to its edge. `move` gets the
|
||||||
|
* indices the model takes, the block already taken out.
|
||||||
|
*/
|
||||||
|
export function sortBlocks(blocks: readonly SortBlock[], move: (from: number, to: number) => void): void {
|
||||||
|
if (blocks.length < 2) return;
|
||||||
|
blocks.forEach((block, index) => {
|
||||||
|
block.grip.eventMode = 'static';
|
||||||
|
block.grip.cursor = 'grab';
|
||||||
|
block.grip.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||||
|
if (e.button === 0) new BlockDrag(blocks, index, e.global.y, move);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockDrag {
|
||||||
|
private readonly column: Container | null;
|
||||||
|
private readonly scroll: ScrollContainer | null;
|
||||||
|
private readonly line = new Graphics();
|
||||||
|
private readonly alphas: number[] = [];
|
||||||
|
private readonly pointer = new Point();
|
||||||
|
private active = false;
|
||||||
|
private readonly unbind: () => void;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly blocks: readonly SortBlock[],
|
||||||
|
private readonly from: number,
|
||||||
|
private readonly startY: number,
|
||||||
|
private readonly move: (from: number, to: number) => void,
|
||||||
|
) {
|
||||||
|
const block = blocks[from]!;
|
||||||
|
this.column = block.rows[0]?.parent ?? null;
|
||||||
|
this.scroll = scrollOf(this.column);
|
||||||
|
const grip = block.grip;
|
||||||
|
const onMove = (e: FederatedPointerEvent) => this.onMove(e);
|
||||||
|
const onUp = () => this.finish(true);
|
||||||
|
const onGone = () => this.finish(false);
|
||||||
|
grip.on('globalpointermove', onMove);
|
||||||
|
grip.on('pointerup', onUp);
|
||||||
|
grip.on('pointerupoutside', onUp);
|
||||||
|
// A rebuild of the rows mid-drag takes the grip away – and with it the release.
|
||||||
|
grip.once('destroyed', onGone);
|
||||||
|
this.unbind = () => {
|
||||||
|
grip.off('globalpointermove', onMove);
|
||||||
|
grip.off('pointerup', onUp);
|
||||||
|
grip.off('pointerupoutside', onUp);
|
||||||
|
grip.off('destroyed', onGone);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get rows(): readonly Widget[] {
|
||||||
|
return this.blocks[this.from]!.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMove(e: FederatedPointerEvent): void {
|
||||||
|
this.pointer.copyFrom(e.global);
|
||||||
|
if (!this.active && Math.abs(e.global.y - this.startY) < DRAG_THRESHOLD) return;
|
||||||
|
if (!this.column) return;
|
||||||
|
if (!this.active) {
|
||||||
|
this.active = true;
|
||||||
|
for (const row of this.rows) {
|
||||||
|
this.alphas.push(row.alpha);
|
||||||
|
row.alpha = DIMMED;
|
||||||
|
}
|
||||||
|
this.column.addChild(this.line);
|
||||||
|
}
|
||||||
|
this.drawLine();
|
||||||
|
this.followEdge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocks' extents in the column, read now: rows may have moved since the drag began. */
|
||||||
|
private spans(): BlockSpan[] {
|
||||||
|
return this.blocks.map(({ rows }) => ({
|
||||||
|
top: Math.min(...rows.map((row) => row.y)),
|
||||||
|
bottom: Math.max(...rows.map((row) => row.y + row.h)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private localY(): number {
|
||||||
|
return this.column ? this.column.toLocal(this.pointer).y : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only where a drop would move the block: a line where it already stands promises nothing. */
|
||||||
|
private drawLine(): void {
|
||||||
|
const column = this.column;
|
||||||
|
this.line.clear();
|
||||||
|
if (!column) return;
|
||||||
|
const spans = this.spans();
|
||||||
|
const y = this.localY();
|
||||||
|
if (dropSlot(y, spans, this.from) === null) return;
|
||||||
|
// From the grip's own indent, so a field's line shows it stays inside its struct.
|
||||||
|
const left = column.toLocal(this.blocks[this.from]!.grip.getGlobalPosition()).x;
|
||||||
|
const right = Math.max(...this.rows.map((row) => row.x + row.w));
|
||||||
|
const at = Math.round(gapLineY(blockGap(y, spans), spans));
|
||||||
|
this.line.rect(left, at - 1, Math.max(0, right - left), 2).fill(Theme.colors.accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls a frame's worth while the pointer stays at the column's edge, and asks for the next. */
|
||||||
|
private followEdge(): void {
|
||||||
|
const scroll = this.scroll;
|
||||||
|
if (!scroll || !this.active) return;
|
||||||
|
onFrame(FRAME_KEY, () => {
|
||||||
|
if (!this.active) return;
|
||||||
|
const before = scroll.offset;
|
||||||
|
scroll.scrollTo(before + edgeScroll(this.pointer.y, scroll.getGlobalPosition().y, scroll.h));
|
||||||
|
if (scroll.offset === before) return;
|
||||||
|
this.drawLine();
|
||||||
|
this.followEdge();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(drop: boolean): void {
|
||||||
|
this.unbind();
|
||||||
|
cancelFrameWork(FRAME_KEY);
|
||||||
|
if (!this.active) return;
|
||||||
|
this.active = false;
|
||||||
|
const to = drop ? dropSlot(this.localY(), this.spans(), this.from) : null;
|
||||||
|
this.rows.forEach((row, i) => {
|
||||||
|
if (!row.destroyed) row.alpha = this.alphas[i] ?? 1;
|
||||||
|
});
|
||||||
|
if (!this.line.destroyed) this.line.destroy();
|
||||||
|
if (to !== null) this.move(this.from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollOf(node: Container | null): ScrollContainer | null {
|
||||||
|
for (let parent = node; parent; parent = parent.parent)
|
||||||
|
if (parent instanceof ScrollContainer) return parent;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+2
-1
@@ -65,7 +65,8 @@ export {
|
|||||||
type SortableValue,
|
type SortableValue,
|
||||||
type ThumbClip,
|
type ThumbClip,
|
||||||
} from './widgets/SortableList';
|
} from './widgets/SortableList';
|
||||||
export { dropGap, dropMove, rowOrder } from './sortable';
|
export { dropGap, dropIndex, dropMove, rowOrder } from './sortable';
|
||||||
|
export { type SortBlock, sortBlocks } from './blockDrag';
|
||||||
export { Popup, type PopupOptions } from './widgets/Popup';
|
export { Popup, type PopupOptions } from './widgets/Popup';
|
||||||
export {
|
export {
|
||||||
currentMenu,
|
currentMenu,
|
||||||
|
|||||||
+59
-1
@@ -1,10 +1,12 @@
|
|||||||
|
import type { DropSpot } from './widgets/ListView';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a sortable list maps between what is on screen and what its owner holds. A layer list shows the
|
* How a sortable list maps between what is on screen and what its owner holds. A layer list shows the
|
||||||
* drawing order upside down – the item drawn last is the topmost row – so every index has two
|
* drawing order upside down – the item drawn last is the topmost row – so every index has two
|
||||||
* readings, and dropping a dragged row has a third: the place it lands in is counted with the row
|
* readings, and dropping a dragged row has a third: the place it lands in is counted with the row
|
||||||
* still in the list, while the model takes it out first.
|
* still in the list, while the model takes it out first.
|
||||||
*
|
*
|
||||||
* Kept apart from the widget so the arithmetic can be measured without a canvas.
|
* Kept apart from the widgets so the arithmetic can be measured without a canvas.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The ids as the list shows them, top first. `reversed` puts the model's last item on top. */
|
/** The ids as the list shows them, top first. `reversed` puts the model's last item on top. */
|
||||||
@@ -36,3 +38,59 @@ export function dropMove(
|
|||||||
const last = count - 1;
|
const last = count - 1;
|
||||||
return { from: last - fromRow, to: last - toRow };
|
return { from: last - fromRow, to: last - toRow };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 rows of one block, top to bottom – a struct with its fields, an item with its nested list. */
|
||||||
|
export interface BlockSpan {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gap a block dropped at `y` falls into, 0…count: past every block whose middle is above `y`.
|
||||||
|
* Blocks differ in height, so the middle of each is what the pointer has to cross.
|
||||||
|
*/
|
||||||
|
export function blockGap(y: number, spans: readonly BlockSpan[]): number {
|
||||||
|
return spans.filter((span) => (span.top + span.bottom) / 2 < y).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the line for `gap` is drawn: halfway between the blocks around it, on the edge at either end. */
|
||||||
|
export function gapLineY(gap: number, spans: readonly BlockSpan[]): number {
|
||||||
|
const above = spans[gap - 1];
|
||||||
|
const below = spans[gap];
|
||||||
|
if (above && below) return (above.bottom + below.top) / 2;
|
||||||
|
return above?.bottom ?? below?.top ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The place block `from` moves to when dropped at `y`, or null where it would stay. */
|
||||||
|
export function dropSlot(y: number, spans: readonly BlockSpan[], from: number): number | null {
|
||||||
|
return dropMove(spans.length, from, blockGap(y, spans), false)?.to ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a scrolled column moves this frame while a drag holds the pointer near its top or bottom:
|
||||||
|
* the deeper into the `edge` band, the faster, up to `step`. Positive scrolls down.
|
||||||
|
*/
|
||||||
|
export function edgeScroll(y: number, top: number, height: number, edge = 24, step = 12): number {
|
||||||
|
const into = (depth: number) => Math.ceil((Math.min(edge, depth) / edge) * step);
|
||||||
|
if (y < top + edge) return -into(top + edge - y);
|
||||||
|
if (y > top + height - edge) return into(y - (top + height - edge));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { dropIndex } from '../../src/editor/tile/PictureStrip';
|
import { dropIndex } from '../../src/ui/sortable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reordering the pictures of a tile by dragging a row (#118). The index the move command
|
* Reordering the pictures of a tile by dragging a row (#118). The index the move command
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const EDIT: VariableEditing = {
|
|||||||
renameField: () => {},
|
renameField: () => {},
|
||||||
removeField: () => {},
|
removeField: () => {},
|
||||||
addField: () => {},
|
addField: () => {},
|
||||||
|
moveField: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = (host: InspectorHost): Widget[][] =>
|
const rows = (host: InspectorHost): Widget[][] =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* shows which item, which gap a drop falls into, and what move that makes in the owner's order.
|
* shows which item, which gap a drop falls into, and what move that makes in the owner's order.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { dropGap, dropMove, rowOrder } from '../../src/ui/sortable';
|
import { blockGap, dropGap, dropMove, dropSlot, edgeScroll, gapLineY, rowOrder } from '../../src/ui/sortable';
|
||||||
|
|
||||||
const ROW = 30;
|
const ROW = 30;
|
||||||
|
|
||||||
@@ -46,3 +46,45 @@ describe('sortable list', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('blocks of rows (#310)', () => {
|
||||||
|
// A one-row variable, a struct of four rows, another one-row variable, with gaps between.
|
||||||
|
const SPANS = [
|
||||||
|
{ top: 0, bottom: 24 },
|
||||||
|
{ top: 28, bottom: 140 },
|
||||||
|
{ top: 144, bottom: 168 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('drops past every block whose middle the pointer has crossed, however tall it is', () => {
|
||||||
|
expect(blockGap(-10, SPANS)).toBe(0);
|
||||||
|
expect(blockGap(11, SPANS)).toBe(0);
|
||||||
|
expect(blockGap(13, SPANS)).toBe(1);
|
||||||
|
// Inside the tall block but above its middle (84): still in front of it.
|
||||||
|
expect(blockGap(80, SPANS)).toBe(1);
|
||||||
|
expect(blockGap(90, SPANS)).toBe(2);
|
||||||
|
expect(blockGap(500, SPANS)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the line between the blocks, on the edge at either end', () => {
|
||||||
|
expect(gapLineY(0, SPANS)).toBe(0);
|
||||||
|
expect(gapLineY(1, SPANS)).toBe(26);
|
||||||
|
expect(gapLineY(2, SPANS)).toBe(142);
|
||||||
|
expect(gapLineY(3, SPANS)).toBe(168);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the drop into the place the model takes, or none where the block stays', () => {
|
||||||
|
expect(dropSlot(500, SPANS, 0)).toBe(2);
|
||||||
|
expect(dropSlot(-10, SPANS, 2)).toBe(0);
|
||||||
|
expect(dropSlot(90, SPANS, 0)).toBe(1);
|
||||||
|
expect(dropSlot(80, SPANS, 1)).toBeNull();
|
||||||
|
expect(dropSlot(150, SPANS, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls at the edges only, faster the deeper the pointer goes into them', () => {
|
||||||
|
expect(edgeScroll(200, 100, 400)).toBe(0);
|
||||||
|
expect(edgeScroll(118, 100, 400)).toBe(-3);
|
||||||
|
expect(edgeScroll(100, 100, 400)).toBe(-12);
|
||||||
|
expect(edgeScroll(40, 100, 400)).toBe(-12);
|
||||||
|
expect(edgeScroll(490, 100, 400)).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ export async function structVariable(t) {
|
|||||||
shot,
|
shot,
|
||||||
click,
|
click,
|
||||||
clickText,
|
clickText,
|
||||||
|
drag,
|
||||||
press,
|
press,
|
||||||
typeText,
|
typeText,
|
||||||
mutate,
|
mutate,
|
||||||
@@ -214,6 +215,48 @@ export async function structVariable(t) {
|
|||||||
await undo();
|
await undo();
|
||||||
check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece()));
|
check('undo takes both back out', !('field1' in (await piece())), JSON.stringify(await piece()));
|
||||||
|
|
||||||
|
// 22f-iii. Reordering by dragging the mark (#310): a variable past a struct of several rows, and a
|
||||||
|
// field among the fields of its struct. A drop just above a mark lands in front of its row.
|
||||||
|
await mutate(`
|
||||||
|
const asset = __gm.state.project.assets.find((a) => a.id === 'structy');
|
||||||
|
asset.variables.push({ key: 'count', label: 'count', kind: 'number', default: 0 });
|
||||||
|
asset.props.count = 5;
|
||||||
|
`);
|
||||||
|
const order = () =>
|
||||||
|
json(
|
||||||
|
"(() => { const v = __gm.state.project.assets.find((a) => a.id === 'structy').variables;" +
|
||||||
|
" return v.map((d) => d.key).join(',') + '|' + v[v.findIndex((d) => d.key === 'piece')].fields.map((f) => f.key).join(','); })()",
|
||||||
|
);
|
||||||
|
const dragMark = async (from, onto) => {
|
||||||
|
const grip = await json(`__gm.inspectorMark(${JSON.stringify(from)})`);
|
||||||
|
const target = await json(`__gm.inspectorMark(${JSON.stringify(onto)})`);
|
||||||
|
await drag(grip.x, grip.y, target.x, target.y - 6);
|
||||||
|
};
|
||||||
|
await dragMark('count', 'piece');
|
||||||
|
check(
|
||||||
|
'dragging a variable by its mark puts it in front of the struct, fields and all',
|
||||||
|
(await order()) === 'count,piece|column,tag',
|
||||||
|
await order(),
|
||||||
|
);
|
||||||
|
await dragMark('tag', 'column');
|
||||||
|
check(
|
||||||
|
'a field moves among the fields of its struct',
|
||||||
|
(await order()) === 'count,piece|tag,column',
|
||||||
|
await order(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'and the values stay with their names',
|
||||||
|
JSON.stringify(await piece()) === '{"column":30,"tag":"tee"}',
|
||||||
|
);
|
||||||
|
await undo();
|
||||||
|
await undo();
|
||||||
|
check('each move is one undo step', (await order()) === 'piece,count|column,tag', await order());
|
||||||
|
await mutate(`
|
||||||
|
const asset = __gm.state.project.assets.find((a) => a.id === 'structy');
|
||||||
|
asset.variables = asset.variables.filter((v) => v.key !== 'count');
|
||||||
|
delete asset.props.count;
|
||||||
|
`);
|
||||||
|
|
||||||
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
|
||||||
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
|
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
|
||||||
// shows (the asset's values) rather than from the empty form of the declaration.
|
// shows (the asset's values) rather than from the empty form of the declaration.
|
||||||
|
|||||||
Reference in new issue
Block a user