Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number field; a struct field can hold a list, so ListSlot carries an optional field name and the three owner cases only say what sits at a key; and a list variable picks its item kind in the inspector (marker doubles as the chooser), so a list of structs no longer has to be born from a project file. The declaration keeps the storable shapes finite: a struct field may be a list, but that list holds primitives only, so list->struct->primitive and struct->list->primitive are the two deepest forms. check 674, e2e 483, jump-run 24, Tetris 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe3c63c48c
commit
10d030376a
18 files changed
+984
-184
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
type ListValue,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
@@ -8,10 +10,13 @@ import {
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
emptyField,
|
||||
coerceValue,
|
||||
emptyFieldValue,
|
||||
isCommonProp,
|
||||
type ListItemKind,
|
||||
type PrimitiveFieldDef,
|
||||
primitiveFieldsOf,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
variableDef,
|
||||
@@ -151,16 +156,36 @@ 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 {
|
||||
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
|
||||
interface FieldOwner {
|
||||
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
|
||||
readonly inList: boolean;
|
||||
fields(): readonly StructFieldDef[];
|
||||
setFields(next: readonly StructFieldDef[]): void;
|
||||
}
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
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;
|
||||
if (def?.kind === 'struct')
|
||||
return {
|
||||
inList: false,
|
||||
fields: () => def.fields,
|
||||
setFields: (next) => (def.fields = next),
|
||||
};
|
||||
if (def?.kind === 'list' && def.items === 'struct')
|
||||
return {
|
||||
inList: true,
|
||||
fields: () => def.fields ?? [],
|
||||
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
|
||||
};
|
||||
throw new CommandError(`Variable "${key}" has no fields`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every value of one variable that carries a struct: the declared default, the behavior's own, and
|
||||
* each instance override – and for a list of structs, every item of each of those. 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;
|
||||
@@ -169,35 +194,68 @@ interface StructSite {
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const holders = valueHolders(project, assetId, key).filter(
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
|
||||
);
|
||||
const sites: StructSite[] = [];
|
||||
for (const holder of holders) {
|
||||
if (def?.kind !== 'list') {
|
||||
sites.push({
|
||||
id: holder.id,
|
||||
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
||||
write: (value) => holder.write(value),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const list = holder.read();
|
||||
if (!isList(list)) continue;
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
sites.push({
|
||||
id: `${holder.id}#${index}`,
|
||||
read: () => {
|
||||
const item = (holder.read() as ListValue)[index];
|
||||
return isStruct(item) ? item : {};
|
||||
},
|
||||
write: (value) =>
|
||||
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
|
||||
});
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/** 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 = requireBehavior(project, assetId);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
const sites: StructSite[] = [];
|
||||
if (def?.kind === 'struct')
|
||||
sites.push({
|
||||
const holders: ValueHolder[] = [];
|
||||
if (def)
|
||||
holders.push({
|
||||
id: 'declared',
|
||||
optional: false,
|
||||
read: () => def.default,
|
||||
write: (value) => {
|
||||
def.default = value;
|
||||
},
|
||||
});
|
||||
sites.push(bagSite('asset', asset.props, key));
|
||||
holders.push(bagHolder('asset', false, 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;
|
||||
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
|
||||
return holders;
|
||||
}
|
||||
|
||||
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 bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
|
||||
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
||||
}
|
||||
|
||||
function withoutField(value: StructValue, key: string): StructValue {
|
||||
@@ -225,18 +283,20 @@ export class AddStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const owner = requireFieldOwner(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))
|
||||
if (owner.fields().some((f) => f.key === this.field.key))
|
||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||
def.fields = [...def.fields, { ...this.field }];
|
||||
if (owner.inList && this.field.kind === 'list')
|
||||
throw new CommandError('A list field cannot sit in the items of a list');
|
||||
owner.setFields([...owner.fields(), { ...this.field }]);
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
|
||||
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
def.fields = def.fields.filter((f) => f.key !== this.field.key);
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
owner.setFields(owner.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));
|
||||
}
|
||||
@@ -256,8 +316,8 @@ export class RemoveStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.fields];
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
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>();
|
||||
@@ -267,16 +327,16 @@ export class RemoveStructFieldCommand implements Command {
|
||||
if (item !== undefined) values.set(site.id, item);
|
||||
site.write(withoutField(value, this.field));
|
||||
}
|
||||
def.fields = fields;
|
||||
owner.setFields(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];
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
const fields = [...owner.fields()];
|
||||
restoreAt(fields, [this.removed.field]);
|
||||
def.fields = fields;
|
||||
owner.setFields(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 });
|
||||
@@ -305,13 +365,82 @@ export class RenameStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const owner = requireFieldOwner(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));
|
||||
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))
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes what a list variable holds. The declaration is the only place that says it, so every
|
||||
* value has to follow at once – each is run through `coerceValue` against the new declaration,
|
||||
* which is the same conversion an item edited in the List tab goes through.
|
||||
*/
|
||||
export class SetListItemsCommand implements Command {
|
||||
readonly label: string;
|
||||
private before?: {
|
||||
items: ListItemKind;
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
values: Map<string, PropertyValue>;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly items: ListItemKind,
|
||||
) {
|
||||
this.label = `Set ${key} items`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
if (def.items === this.items)
|
||||
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
|
||||
const values = new Map<string, PropertyValue>();
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) values.set(holder.id, value);
|
||||
}
|
||||
this.before = { items: def.items, fields: def.fields, values };
|
||||
def.items = this.items;
|
||||
// Fields belong to struct items alone: kept while leaving so an undo has them back.
|
||||
if (this.items === 'struct' && !def.fields) def.fields = [];
|
||||
this.recoerce(project);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.before) return;
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
def.items = this.before.items;
|
||||
if (this.before.fields) def.fields = this.before.fields;
|
||||
else delete def.fields;
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = this.before.values.get(holder.id);
|
||||
if (value !== undefined) holder.write(value);
|
||||
}
|
||||
}
|
||||
|
||||
private recoerce(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) holder.write(coerceValue(def, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireListDef(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
key: string,
|
||||
): Extract<PropertyDef, { kind: 'list' }> {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
|
||||
return def;
|
||||
}
|
||||
Reference in new issue
Block a user