Files
feuerwerk/src/core/commands/variableCommands.ts
T
adminandClaude Opus 5.5 2c19c89878
game-maker / gate (push) Successful in 3m10s
game-maker / suite (push) Failing after 19m34s
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>
2026-10-03 21:45:04 +02:00

508 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}