diff --git a/README.md b/README.md index 578d738f..2d6922ae 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index f714e404..556ed873 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -23,8 +23,8 @@ import { isGraphAsset } from '../model/Asset'; import type { PropertyValue } from '../model/Properties'; import { kindLabel } from '../properties/kinds'; import type { Project } from '../model/Project'; -import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; -import { type Command, CommandError, RenameCommand } from './Command'; +import { moveItem, type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; +import { type Command, CommandError, MoveCommand, RenameCommand } from './Command'; import { nextFieldName } from './naming'; 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. */ function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void { const ports = node.ports ?? []; diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 34138e29..29ff71ec 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -27,8 +27,8 @@ import { variableDef, } from '../properties/PropertySchema'; import { valueAt, withValueAt } from '../properties/valuePath'; -import { type Removed, removeFirst, restoreAt } from '../util/list'; -import { type Command, CommandError, RenameCommand } from './Command'; +import { moveItem, type Removed, removeFirst, restoreAt } from '../util/list'; +import { type Command, CommandError, MoveCommand, RenameCommand } from './Command'; import { renameVariablePorts } from './portCommands'; 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`. */ function requireBehaviorVariable(project: Project, assetId: string, key: string): BehaviorAsset { const asset = requireBehavior(project, assetId); @@ -467,3 +483,25 @@ export class RenameStructFieldCommand extends RenameCommand { 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); + } +} diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index c343eea9..51c02f47 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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); diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index f1f99211..3efce6cc 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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))); } /** diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts index 85a488f1..e6e4f4e8 100644 --- a/src/editor/inspector/host.ts +++ b/src/editor/inspector/host.ts @@ -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(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('')); } diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index c272ed32..aeac62df 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -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] }; } /** diff --git a/src/editor/inspector/variableRows.ts b/src/editor/inspector/variableRows.ts index c7994a15..02c27728 100644 --- a/src/editor/inspector/variableRows.ts +++ b/src/editor/inspector/variableRows.ts @@ -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(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 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. */ diff --git a/src/editor/list/InlineList.ts b/src/editor/list/InlineList.ts index 0af3f260..29f1bb32 100644 --- a/src/editor/list/InlineList.ts +++ b/src/editor/list/InlineList.ts @@ -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. */ diff --git a/src/editor/tile/PictureStrip.ts b/src/editor/tile/PictureStrip.ts index 968ece6e..06f4851c 100644 --- a/src/editor/tile/PictureStrip.ts +++ b/src/editor/tile/PictureStrip.ts @@ -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 diff --git a/src/ui/blockDrag.ts b/src/ui/blockDrag.ts new file mode 100644 index 00000000..68db99b5 --- /dev/null +++ b/src/ui/blockDrag.ts @@ -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; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index bc6834f4..d6fa2395 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -65,7 +65,8 @@ export { type SortableValue, type ThumbClip, } 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 { currentMenu, diff --git a/src/ui/sortable.ts b/src/ui/sortable.ts index e6bab3e5..919880a0 100644 --- a/src/ui/sortable.ts +++ b/src/ui/sortable.ts @@ -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 * 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 * 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. */ @@ -36,3 +38,59 @@ export function dropMove( const last = count - 1; 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; +} diff --git a/tests/core/reorder.test.ts b/tests/core/reorder.test.ts new file mode 100644 index 00000000..f4277fce --- /dev/null +++ b/tests/core/reorder.test.ts @@ -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']); + }); +}); diff --git a/tests/editor/tile-strip.test.ts b/tests/editor/tile-strip.test.ts index 2ca59715..d30fab00 100644 --- a/tests/editor/tile-strip.test.ts +++ b/tests/editor/tile-strip.test.ts @@ -1,5 +1,5 @@ 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 diff --git a/tests/editor/variable-rows.test.ts b/tests/editor/variable-rows.test.ts index 1f52a8b9..5f95e6cb 100644 --- a/tests/editor/variable-rows.test.ts +++ b/tests/editor/variable-rows.test.ts @@ -54,6 +54,7 @@ const EDIT: VariableEditing = { renameField: () => {}, removeField: () => {}, addField: () => {}, + moveField: () => {}, }; const rows = (host: InspectorHost): Widget[][] => diff --git a/tests/ui/sortable.test.ts b/tests/ui/sortable.test.ts index 1cd27d3d..bb6ac7fb 100644 --- a/tests/ui/sortable.test.ts +++ b/tests/ui/sortable.test.ts @@ -3,7 +3,7 @@ * 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 { dropGap, dropMove, rowOrder } from '../../src/ui/sortable'; +import { blockGap, dropGap, dropMove, dropSlot, edgeScroll, gapLineY, rowOrder } from '../../src/ui/sortable'; 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); + }); +}); diff --git a/tools/e2e/22-struct.mjs b/tools/e2e/22-struct.mjs index 5150c548..3e78c62e 100644 --- a/tools/e2e/22-struct.mjs +++ b/tools/e2e/22-struct.mjs @@ -12,6 +12,7 @@ export async function structVariable(t) { shot, click, clickText, + drag, press, typeText, mutate, @@ -214,6 +215,48 @@ export async function structVariable(t) { await undo(); 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 // 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.