Let a variable declare struct fields
The value type was already there: StructValue, textOf, sameValue, writeTyped/mergeStruct and the struct port type came with the first part, and Get/Set variable carry any-ports, so a bundle already flowed through the graph unchanged. What was missing was the declaration. PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a fifth kind, variableDef a fifth parameter. A declared field is a primitive - a list field would need a nested editor and therefore the path in the slot address, the same step "a list of structs" is waiting for. coerceValue now returns PropertyValue rather than FieldValue, the first value type that is not a field; none of its five callers break. In the inspector a struct variable is a block, not a tab. The list tab exists because hundreds of rows fit in no inspector row; a struct is the opposite case - few fields that fit under each other. It is the port editor Pack already has, one level down and with a value column: type, key and default value in one place. A placed object sees the same fields as plain value rows: overriding yes, declaring no. Adding or removing a field touches the declaration, the default, the behavior's value and every instance override in one command, so a stored value can never keep a shape its declaration no longer promises. The row handlers read the bundle at the moment of the change rather than closing over a snapshot - a field writes the whole bundle, so with a snapshot the second edited row revives the first one's old value. defSignature carries the fields, otherwise a new row appears only at the next rebuild. Fixes a fixture fault this uncovered: step 10 pushed its text object into the scene without instanceEdits, which overrideSites walks with Object.entries. AddStructFieldCommand is the first caller to reach it from that state; the step was green alone and failed three checks in the full suite. Moves the dynamic-ports note to done - all three of its parts are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d07946c74
commit
0449218cb6
17 files changed
+1146
-69
No files matched your search
@@ -1,7 +1,21 @@
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import {
|
||||
type FieldValue,
|
||||
isStruct,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
emptyField,
|
||||
isCommonProp,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
@@ -123,8 +137,179 @@ export class RenameVariableCommand implements Command {
|
||||
}
|
||||
|
||||
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 _';
|
||||
|
||||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
|
||||
return isValidFieldName(name) && !isCommonProp(name);
|
||||
}
|
||||
|
||||
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
|
||||
export function isValidFieldName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
function requireStruct(project: Project, assetId: string, key: string): StructDef {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
if (!def || def.kind !== 'struct') throw new CommandError(`Variable "${key}" is not a struct`);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value of one struct variable: the declared default, the behavior's own, and each instance
|
||||
* override. A field is added to or taken from all of them at once, so no value keeps a shape its
|
||||
* declaration no longer promises. The `id` is what pairs a site up again when the change is undone.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
read(): StructValue;
|
||||
write(value: StructValue): void;
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const asset = requireBehavior(project, assetId);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
const sites: StructSite[] = [];
|
||||
if (def?.kind === 'struct')
|
||||
sites.push({
|
||||
id: 'declared',
|
||||
read: () => def.default,
|
||||
write: (value) => {
|
||||
def.default = value;
|
||||
},
|
||||
});
|
||||
sites.push(bagSite('asset', asset.props, key));
|
||||
for (const site of overrideSites(project, (id) => id === assetId))
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
if (isStruct(site.bag[key])) sites.push(bagSite(`${site.object.id}/${site.path}`, site.bag, key));
|
||||
return sites;
|
||||
}
|
||||
|
||||
function bagSite(id: string, bag: PropertyBag, key: string): StructSite {
|
||||
return {
|
||||
id,
|
||||
read: () => {
|
||||
const value = bag[key];
|
||||
return isStruct(value) ? value : {};
|
||||
},
|
||||
write: (value) => {
|
||||
bag[key] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutField(value: StructValue, key: string): StructValue {
|
||||
const next: Record<string, FieldValue> = { ...value };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */
|
||||
function renameField(value: StructValue, from: string, to: string): StructValue {
|
||||
const next: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
export class AddStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly field: StructFieldDef,
|
||||
) {
|
||||
this.label = `Add field ${field.key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
||||
if (def.fields.some((f) => f.key === this.field.key))
|
||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||
def.fields = [...def.fields, { ...this.field }];
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
def.fields = def.fields.filter((f) => f.key !== this.field.key);
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
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> };
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly field: string,
|
||||
) {
|
||||
this.label = `Remove field ${field}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.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)) {
|
||||
const value = site.read();
|
||||
const item = value[this.field];
|
||||
if (item !== undefined) values.set(site.id, item);
|
||||
site.write(withoutField(value, this.field));
|
||||
}
|
||||
def.fields = fields;
|
||||
this.removed = { field, values };
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.fields];
|
||||
restoreAt(fields, [this.removed.field]);
|
||||
def.fields = fields;
|
||||
for (const site of structSites(project, this.assetId, this.key)) {
|
||||
const value = this.removed.values.get(site.id);
|
||||
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
) {
|
||||
this.label = `Rename field ${from}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
if (from === to) return;
|
||||
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
||||
if (!def.fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
||||
if (def.fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||||
def.fields = def.fields.map((f) => (f.key === from ? { ...f, key: to } : f));
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user