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>
508 lines
18 KiB
TypeScript
508 lines
18 KiB
TypeScript
import {
|
||
type FieldValue,
|
||
isStruct,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
withFieldRenamed,
|
||
withoutField,
|
||
} from '../model/Properties';
|
||
import {
|
||
type BehaviorAsset,
|
||
isKeptVariable,
|
||
isPrivateVariable,
|
||
setKeptVariable,
|
||
setPrivateVariable,
|
||
type VariableOwnerAsset,
|
||
} from '../model/Asset';
|
||
import { overrideSites } from '../model/instance';
|
||
import type { Project } from '../model/Project';
|
||
import type { VariableKind } from '../properties/kinds';
|
||
import {
|
||
emptyFieldValue,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
import { valueAt, withValueAt } from '../properties/valuePath';
|
||
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';
|
||
|
||
/**
|
||
* The variable's default value is written to `props` as well. On a behavior a new variable starts
|
||
* at its default every call; "Keep" is switched on by hand (#229).
|
||
*/
|
||
export class AddVariableCommand implements Command {
|
||
readonly label: string;
|
||
readonly def: PropertyDef;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
key: string,
|
||
kind: VariableKind,
|
||
) {
|
||
this.def = variableDef(key, kind);
|
||
this.label = `Add variable ${key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireVariableOwner(project, this.assetId);
|
||
if (!isValidName(asset, this.def.key)) throw new CommandError(INVALID_NAME);
|
||
if (asset.variables.some((v) => v.key === this.def.key))
|
||
throw new CommandError(`Variable "${this.def.key}" already exists`);
|
||
asset.variables.push(this.def);
|
||
asset.props[this.def.key] = this.def.default;
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const asset = requireVariableOwner(project, this.assetId);
|
||
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
|
||
delete asset.props[this.def.key];
|
||
}
|
||
}
|
||
|
||
/** One placed object's own value for a variable, remembered so that undo can put it back. */
|
||
interface TakenOverride {
|
||
holderId: string;
|
||
objectId: string;
|
||
path: string;
|
||
value: PropertyValue;
|
||
}
|
||
|
||
function takeOverrides(project: Project, assetId: string, key: string): TakenOverride[] {
|
||
const taken: TakenOverride[] = [];
|
||
for (const site of overrideSites(project, (id) => id === assetId)) {
|
||
const value = site.bag[key];
|
||
if (value === undefined) continue;
|
||
taken.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
||
delete site.bag[key];
|
||
}
|
||
return taken;
|
||
}
|
||
|
||
function restoreOverrides(
|
||
project: Project,
|
||
assetId: string,
|
||
key: string,
|
||
taken: readonly TakenOverride[],
|
||
): void {
|
||
const sites = overrideSites(project, (id) => id === assetId);
|
||
for (const o of taken) {
|
||
const site = sites.find(
|
||
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
||
);
|
||
if (site) site.bag[key] = o.value;
|
||
}
|
||
}
|
||
|
||
/** Removes a variable together with its default and every instance override. */
|
||
export class RemoveVariableCommand implements Command {
|
||
readonly label: string;
|
||
private removed?: {
|
||
def: Removed<PropertyDef>;
|
||
value: PropertyValue | undefined;
|
||
kept: boolean;
|
||
hidden: boolean;
|
||
};
|
||
private overrides: TakenOverride[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
) {
|
||
this.label = `Remove variable ${key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireVariableOwner(project, this.assetId);
|
||
const def = removeFirst(asset.variables, (v) => v.key === this.key);
|
||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||
const kept = asset.type === 'behavior' && isKeptVariable(asset, this.key);
|
||
const hidden = isPrivateVariable(asset, this.key);
|
||
this.removed = { def, value: asset.props[this.key], kept, hidden };
|
||
delete asset.props[this.key];
|
||
if (asset.type === 'behavior') {
|
||
setKeptVariable(asset, this.key, false);
|
||
setPrivateVariable(asset, this.key, false);
|
||
}
|
||
this.overrides = takeOverrides(project, this.assetId, this.key);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const asset = requireVariableOwner(project, this.assetId);
|
||
restoreAt(asset.variables, [this.removed.def]);
|
||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||
if (asset.type === 'behavior') {
|
||
if (this.removed.kept) setKeptVariable(asset, this.key, true);
|
||
if (this.removed.hidden) setPrivateVariable(asset, this.key, true);
|
||
}
|
||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||
}
|
||
}
|
||
|
||
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
|
||
export class RenameVariableCommand extends RenameCommand {
|
||
constructor(
|
||
private readonly assetId: string,
|
||
from: string,
|
||
to: string,
|
||
) {
|
||
super(`Rename variable ${from}`, from, to);
|
||
}
|
||
|
||
protected rename(project: Project, from: string, to: string): void {
|
||
const asset = requireVariableOwner(project, this.assetId);
|
||
if (from === to) return;
|
||
if (!isValidName(asset, to)) throw new CommandError(INVALID_NAME);
|
||
const def = asset.variables.find((v) => v.key === from);
|
||
if (!def) throw new CommandError(`Variable "${from}" not found`);
|
||
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
|
||
def.key = to;
|
||
def.label = to;
|
||
// Renamed in place, so the switches keep their spot in the list.
|
||
if (asset.type === 'behavior') {
|
||
if (asset.keptVariables) asset.keptVariables = asset.keptVariables.map((k) => (k === from ? to : k));
|
||
if (asset.privateVariables)
|
||
asset.privateVariables = asset.privateVariables.map((k) => (k === from ? to : k));
|
||
}
|
||
if (asset.props[from] !== undefined) {
|
||
asset.props[to] = asset.props[from]!;
|
||
delete asset.props[from];
|
||
}
|
||
for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
|
||
if (bag[from] === undefined) continue;
|
||
bag[to] = bag[from]!;
|
||
delete bag[from];
|
||
}
|
||
renameVariablePorts(project, this.assetId, from, to);
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
if (!asset.variables.some((v) => v.key === key)) throw new CommandError(`Variable "${key}" not found`);
|
||
return asset;
|
||
}
|
||
|
||
/** Whether a behavior's variable carries on from one `Run behavior` call to the next (#229). */
|
||
export class SetVariableKeptCommand implements Command {
|
||
readonly label: string;
|
||
private previous = false;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
private readonly kept: boolean,
|
||
) {
|
||
this.label = kept ? `Keep ${key} between calls` : `Reset ${key} every call`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireBehaviorVariable(project, this.assetId, this.key);
|
||
this.previous = isKeptVariable(asset, this.key);
|
||
setKeptVariable(asset, this.key, this.kept);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
setKeptVariable(requireBehavior(project, this.assetId), this.key, this.previous);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a behavior's variable is seen outside its own graph (#257). Going private drops the
|
||
* overrides of every placed object: nobody would see them any more, yet they would still act.
|
||
*/
|
||
export class SetVariablePrivateCommand implements Command {
|
||
readonly label: string;
|
||
private previous = false;
|
||
private overrides: TakenOverride[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
private readonly hidden: boolean,
|
||
) {
|
||
this.label = hidden ? `Make ${key} private` : `Make ${key} public`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireBehaviorVariable(project, this.assetId, this.key);
|
||
this.previous = isPrivateVariable(asset, this.key);
|
||
setPrivateVariable(asset, this.key, this.hidden);
|
||
this.overrides = this.hidden ? takeOverrides(project, this.assetId, this.key) : [];
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
setPrivateVariable(requireBehavior(project, this.assetId), this.key, this.previous);
|
||
restoreOverrides(project, this.assetId, this.key, this.overrides);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A struct's fields are no properties of anything placed, so the reserved property names are free
|
||
* there – as they are for a struct field.
|
||
*/
|
||
function isValidName(asset: VariableOwnerAsset, name: string): boolean {
|
||
return asset.type === 'struct' ? isValidFieldName(name) : isValidVariableName(name);
|
||
}
|
||
|
||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||
|
||
/**
|
||
* Where declared fields live: on a struct variable, or on a struct field of one, however deep
|
||
* (#239). A list item carries its own shape instead.
|
||
*/
|
||
interface FieldOwner {
|
||
fields(): readonly StructFieldDef[];
|
||
setFields(next: readonly StructFieldDef[]): void;
|
||
}
|
||
|
||
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
|
||
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
|
||
}
|
||
|
||
/** `path` names the struct fields from the variable down to the struct whose fields are meant. */
|
||
function requireFieldOwner(
|
||
project: Project,
|
||
assetId: string,
|
||
key: string,
|
||
path: readonly string[],
|
||
): FieldOwner {
|
||
const def = variableOf(project, assetId, key);
|
||
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
|
||
if (fieldsAt(def.fields, path) === undefined)
|
||
throw new CommandError(`Field "${path.join('.')}" has no fields`);
|
||
return {
|
||
fields: () => fieldsAt(def.fields, path) ?? [],
|
||
setFields: (next) => (def.fields = withFieldsAt(def.fields, path, next)),
|
||
};
|
||
}
|
||
|
||
function fieldsAt(
|
||
fields: readonly StructFieldDef[],
|
||
path: readonly string[],
|
||
): readonly StructFieldDef[] | undefined {
|
||
if (path.length === 0) return fields;
|
||
const field = fields.find((f) => f.key === path[0]);
|
||
return field?.kind === 'struct' ? fieldsAt(field.fields, path.slice(1)) : undefined;
|
||
}
|
||
|
||
/** The declaration with the fields at `path` replaced, rebuilt on the way down. */
|
||
function withFieldsAt(
|
||
fields: readonly StructFieldDef[],
|
||
path: readonly string[],
|
||
next: readonly StructFieldDef[],
|
||
): readonly StructFieldDef[] {
|
||
if (path.length === 0) return next;
|
||
return fields.map((f) =>
|
||
f.key === path[0] && f.kind === 'struct'
|
||
? { ...f, fields: withFieldsAt(f.fields, path.slice(1), next) }
|
||
: f,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
|
||
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
|
||
* With a `path` the site is the struct that deep inside the value.
|
||
*/
|
||
interface StructSite {
|
||
id: string;
|
||
read(): StructValue;
|
||
write(value: StructValue): void;
|
||
}
|
||
|
||
function structSites(project: Project, assetId: string, key: string, path: readonly string[]): StructSite[] {
|
||
const structIn = (value: PropertyValue | undefined): StructValue => {
|
||
const at = valueAt(value, path);
|
||
return isStruct(at) ? at : {};
|
||
};
|
||
return (
|
||
valueHolders(project, assetId, key)
|
||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||
.filter((h) => !h.optional || isStruct(h.read()))
|
||
.map((holder) => ({
|
||
id: holder.id,
|
||
read: () => structIn(holder.read()),
|
||
write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value),
|
||
}))
|
||
);
|
||
}
|
||
|
||
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
|
||
interface ValueHolder {
|
||
id: string;
|
||
/** An override may simply not be there; the declared default and the behavior's own always are. */
|
||
optional: boolean;
|
||
read(): PropertyValue | undefined;
|
||
write(value: PropertyValue): void;
|
||
}
|
||
|
||
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
|
||
const asset = requireVariableOwner(project, assetId);
|
||
const def = asset.variables.find((v) => v.key === key);
|
||
const holders: ValueHolder[] = [];
|
||
if (def)
|
||
holders.push({
|
||
id: 'declared',
|
||
optional: false,
|
||
read: () => def.default,
|
||
write: (value) => {
|
||
def.default = value;
|
||
},
|
||
});
|
||
holders.push(bagHolder('asset', false, asset.props, key));
|
||
for (const site of overrideSites(project, (id) => id === assetId))
|
||
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
|
||
return holders;
|
||
}
|
||
|
||
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
|
||
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
||
}
|
||
|
||
export class AddStructFieldCommand implements Command {
|
||
readonly label: string;
|
||
|
||
/** `path` names the struct the field is added to: none for the variable itself. */
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
private readonly field: StructFieldDef,
|
||
private readonly path: readonly string[] = [],
|
||
) {
|
||
this.label = `Add field ${field.key}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
||
if (owner.fields().some((f) => f.key === this.field.key))
|
||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||
owner.setFields([...owner.fields(), { ...this.field }]);
|
||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
|
||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||
site.write(withoutField(site.read(), this.field.key));
|
||
}
|
||
}
|
||
|
||
/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */
|
||
export class RemoveStructFieldCommand implements Command {
|
||
readonly label: string;
|
||
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
|
||
|
||
/** `path` names the struct the field is taken from: none for the variable itself. */
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
private readonly field: string,
|
||
private readonly path: readonly string[] = [],
|
||
) {
|
||
this.label = `Remove field ${field}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||
const fields = [...owner.fields()];
|
||
const field = removeFirst(fields, (f) => f.key === this.field);
|
||
if (!field) throw new CommandError(`Field "${this.field}" not found`);
|
||
const values = new Map<string, FieldValue>();
|
||
for (const site of structSites(project, this.assetId, this.key, this.path)) {
|
||
const value = site.read();
|
||
const item = value[this.field];
|
||
if (item !== undefined) values.set(site.id, item);
|
||
site.write(withoutField(value, this.field));
|
||
}
|
||
owner.setFields(fields);
|
||
this.removed = { field, values };
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||
const fields = [...owner.fields()];
|
||
restoreAt(fields, [this.removed.field]);
|
||
owner.setFields(fields);
|
||
for (const site of structSites(project, this.assetId, this.key, this.path)) {
|
||
const value = this.removed.values.get(site.id);
|
||
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
||
}
|
||
}
|
||
}
|
||
|
||
export class RenameStructFieldCommand extends RenameCommand {
|
||
/** `path` names the struct the field belongs to: none for the variable itself. */
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly key: string,
|
||
from: string,
|
||
to: string,
|
||
private readonly path: readonly string[] = [],
|
||
) {
|
||
super(`Rename field ${from}`, from, to);
|
||
}
|
||
|
||
protected rename(project: Project, from: string, to: string): void {
|
||
const owner = requireFieldOwner(project, this.assetId, this.key, this.path);
|
||
if (from === to) return;
|
||
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
||
const fields = owner.fields();
|
||
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
||
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||
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);
|
||
}
|
||
}
|