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; 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; values: Map }; /** `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(); 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); } }