Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs, struct with a list field). Anything deeper was dropped silently on load, in node settings and when written to a variable. The cap came from an early design choice, not from a technical need, so it is lifted completely instead of adding a third fixed level. - One kind list NESTED_KINDS replaces ListItemKind and StructFieldKind. The flat special cases in listItems.ts are gone, every value goes through one recursive path. - A struct inside a struct is declared, like a top-level struct. Values coming in over an edge are brought into that shape. Struct items in lists still take their fields from the value. - New valuePath.ts addresses any nested spot by keys and indices. ListSlot.field is now ListSlot.path. - Field commands (add, rename, remove) take the path to the struct. They change declaration, own value and all overrides in one undo step, as before on the top level. - Inspector: "+ Field" offers Struct and List on every level. A nested struct is a header row with its own "+ Field", its fields are indented one step further. - List tab: a list item shows "n items" and opens one level deeper. The title shows the path, e.g. "Listy · nums · [3]". - Pack in Pack now writes a struct into a struct field instead of text. Objects are kept at any depth and stripped at every exit. - A list edit in a struct field without an existing object override now starts from the shown value instead of an empty struct. - Old projects load unchanged. Save, load and save again gives the same result. Tests: - ./check.sh: 172 files, 2498 tests, CHECK EXIT 0 - e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii, 21d and 22f updated) - 20 new unit tests in nested-values.test.ts and nested-list-slot.test.ts, three tests that pinned the old cap turned around - 6 counter-probes, each fails 1 to 8 tests, originals restored and checked with cmp - Not covered: a nested struct inside a struct item in the list tab has no e2e click Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
483c34bb5c
commit
0c5ac32b59
29 files changed
+1046
-437
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
type StructFieldDef,
|
||||
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 { renameVariablePorts } from './portCommands';
|
||||
@@ -189,7 +190,10 @@ export class SetVariableKeptCommand 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 _';
|
||||
|
||||
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
|
||||
/**
|
||||
* 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;
|
||||
@@ -199,15 +203,50 @@ function variableOf(project: Project, assetId: string, key: string): PropertyDef
|
||||
return requireVariableOwner(project, assetId).variables.find((v) => v.key === key);
|
||||
}
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
/** `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`);
|
||||
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
|
||||
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;
|
||||
@@ -215,15 +254,19 @@ interface StructSite {
|
||||
write(value: StructValue): void;
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
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: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
||||
write: (value: StructValue) => holder.write(value),
|
||||
read: () => structIn(holder.read()),
|
||||
write: (value: StructValue) => holder.write(withValueAt(holder.read(), path, value) ?? value),
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -276,28 +319,30 @@ function renameField(value: StructValue, from: string, to: string): StructValue
|
||||
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);
|
||||
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))
|
||||
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);
|
||||
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))
|
||||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||
site.write(withoutField(site.read(), this.field.key));
|
||||
}
|
||||
}
|
||||
@@ -307,21 +352,23 @@ 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);
|
||||
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)) {
|
||||
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);
|
||||
@@ -333,11 +380,11 @@ export class RemoveStructFieldCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
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)) {
|
||||
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 });
|
||||
}
|
||||
@@ -345,24 +392,26 @@ export class RemoveStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
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);
|
||||
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))
|
||||
for (const site of structSites(project, this.assetId, this.key, this.path))
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user