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
@@ -106,11 +106,19 @@ shown on the node and editable in the inspector.
|
||||
inspector would use for that kind – for a list of structs that is the inspector's own struct
|
||||
block, so a row is the bundle's fields under each other. The inspector stays visible beside the tab, so what the list
|
||||
belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object
|
||||
override or node setting) on the editor state, because no tab may reach into the `Editor` to
|
||||
switch itself on. A **struct** variable is the other way round: few fields, so they fit under
|
||||
override or node setting, plus the `field` of a struct that holds one) on the editor state,
|
||||
because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input**
|
||||
of a node is the same button and the same tab: its inline value lives in the very same params
|
||||
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What a list
|
||||
variable holds is picked from its **kind mark** (`≡#`, `≡Aa`, `≡⊞`), which doubles as the
|
||||
chooser; changing it runs the declared default, the behavior's own value and every override
|
||||
through the new declaration in one command. A list of structs then declares its item shape with
|
||||
the same `+ Field` control a struct variable has – names and kinds only, since the values are
|
||||
edited row by row in the tab. A **struct** variable is the other way round: few fields, so they fit under
|
||||
each other right in the inspector. Its head row renames it and adds fields (`+ Field`), each
|
||||
field row below carries its own name, its value and a remove – the port editor `Pack` already
|
||||
has, one level down and with a value column. Adding, renaming or removing a field reaches the
|
||||
has, one level down and with a value column – and a field may itself be a **list**, which is
|
||||
the one count button that opens the tab one level deeper. Adding, renaming or removing a field reaches the
|
||||
declaration, the behavior's own value **and every instance override** in one command, so no
|
||||
stored value keeps a shape its declaration no longer promises. A placed instance sees the same
|
||||
fields as plain value rows: it may override them, not declare them.
|
||||
@@ -160,9 +168,11 @@ shown on the node and editable in the inspector.
|
||||
one node can set part of a bundle without erasing it – "the same piece, one row lower" is a
|
||||
`Pack` of that one field. Depth is bounded by the *declaration*, not by convention: a list says
|
||||
what its items are (`ListItemKind`, struct included), a struct field says what it holds
|
||||
(`FieldKind`, primitives only), so `list → struct → primitive` is as deep as a stored value
|
||||
goes and the deep compare and the editor stay finite. A declared field holding a list is the
|
||||
one step still missing. A live entity is
|
||||
(`StructFieldKind`, primitives plus `list`), and the list a *field* holds takes primitives only
|
||||
(`ListFieldDef`). Forbidding just that one combination is what keeps the shapes finite:
|
||||
`list → struct → primitive` and `struct → list → primitive` are the two deepest a stored value
|
||||
goes – exactly what `asStoredValue` lets through an untyped slot – so the deep compare and the
|
||||
editor stay finite. A live entity is
|
||||
an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct
|
||||
field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead),
|
||||
or a struct would read as `[object Object]`. A struct port has no inline value (its parts come
|
||||
@@ -589,7 +599,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 465 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 483 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { Graph, GraphNode, PortType } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { asBoolean, asNumber, asString, type Value } from './values';
|
||||
import { asBoolean, asList, asNumber, asString, type Value } from './values';
|
||||
|
||||
export interface PortDef {
|
||||
key: string;
|
||||
@@ -135,6 +135,16 @@ export function inlineInputDef(port: PortDef): PropertyDef {
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||||
// No port declares an item kind, so an inline list reads as numbers – the same default
|
||||
// `+ Variable ▸ List` picks. The graph types a list as a list either way.
|
||||
case 'list':
|
||||
return {
|
||||
key: port.key,
|
||||
label: port.label,
|
||||
kind: 'list',
|
||||
default: asList(port.default),
|
||||
items: 'number',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
key: port.key,
|
||||
|
||||
@@ -56,7 +56,7 @@ export type PropertyDef =
|
||||
default: ListValue;
|
||||
items: ListItemKind;
|
||||
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
|
||||
fields?: readonly StructFieldDef[];
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
}
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
@@ -64,19 +64,37 @@ export type PropertyDef =
|
||||
*/
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */
|
||||
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */
|
||||
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
export interface StructFieldDef {
|
||||
/** What a field of a struct property holds – a primitive plus `list`. */
|
||||
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
|
||||
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
|
||||
|
||||
export interface PrimitiveFieldDef {
|
||||
key: string;
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list held by a struct field. Its items are primitives, and that asymmetry is the whole point:
|
||||
* a list may hold structs and a struct may hold lists, so only forbidding one of the two here
|
||||
* keeps the storable shapes finite. `list → struct → primitive` and `struct → list → primitive`
|
||||
* are the two deepest, which is exactly what `asStoredValue` lets through.
|
||||
*/
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
items: FieldKind;
|
||||
}
|
||||
|
||||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
|
||||
|
||||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
@@ -214,7 +232,7 @@ export function variableDef(
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'list': {
|
||||
const item = items ?? 'number';
|
||||
const shape = item === 'struct' ? (fields ?? []) : undefined;
|
||||
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
|
||||
const raw = isList(defaultValue) ? defaultValue : [];
|
||||
return {
|
||||
key,
|
||||
@@ -232,6 +250,10 @@ export function variableDef(
|
||||
}
|
||||
}
|
||||
|
||||
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
|
||||
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
|
||||
}
|
||||
|
||||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||||
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
number: 0,
|
||||
@@ -244,6 +266,10 @@ export function emptyField(kind: FieldKind): number | string | boolean {
|
||||
return EMPTY_FIELD[kind];
|
||||
}
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct over exactly the declared fields: a declared field missing from `from` starts empty
|
||||
* rather than absent, so a struct value always has the shape its declaration promises.
|
||||
@@ -253,7 +279,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
|
||||
const value: Record<string, FieldValue> = {};
|
||||
for (const field of fields)
|
||||
value[field.key] =
|
||||
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
|
||||
field.key in source ? coerceFieldValue(source[field.key]!, field) : emptyFieldValue(field);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -298,6 +324,12 @@ export function coerceListItem(
|
||||
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
|
||||
}
|
||||
|
||||
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
|
||||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||||
if (field.kind !== 'list') return coerceField(value, field.kind);
|
||||
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
|
||||
}
|
||||
|
||||
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
|
||||
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
|
||||
if (items === 'text') return textOf(value);
|
||||
|
||||
@@ -194,13 +194,21 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
const f = asRecord(item);
|
||||
const key = typeof f.key === 'string' ? f.key : '';
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
||||
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
|
||||
const field = readField(key, f);
|
||||
if (!field) continue;
|
||||
seen.add(key);
|
||||
fields.push({ key, kind: f.kind as FieldKind });
|
||||
fields.push(field);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||
if (f.kind === 'list')
|
||||
return { key, kind: 'list', items: isFieldKind(f.items) ? (f.items as FieldKind) : 'number' };
|
||||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -11,16 +11,21 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
STRUCT_FIELD_KINDS,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -32,10 +37,11 @@ import {
|
||||
createField,
|
||||
createFieldRow,
|
||||
type FieldHandlers,
|
||||
fieldDef,
|
||||
labelledRow,
|
||||
primitiveDef,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import type { ListDef } from '../list/slot';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import {
|
||||
listOpener,
|
||||
@@ -62,20 +68,43 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
|
||||
/** The mark a list wears for what it holds – `≡` plus the item's own mark. */
|
||||
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
|
||||
number: 'Numbers',
|
||||
boolean: 'Booleans',
|
||||
text: 'Texts',
|
||||
color: 'Colors',
|
||||
struct: 'Structs',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
list: 'List',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
|
||||
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
|
||||
|
||||
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
list: '≡',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -88,8 +117,11 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
|
||||
const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+');
|
||||
// A list's item kind decides whether it has field rows at all, so it is structure too.
|
||||
const items = def.kind === 'list' ? `/${def.items}` : '';
|
||||
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -181,7 +213,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
|
||||
return {
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||
...structFields<Asset>(host, def, spec),
|
||||
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,9 +245,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
}
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def));
|
||||
row.add(
|
||||
new TextField(def.key, {
|
||||
width: 90,
|
||||
@@ -236,9 +266,84 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
);
|
||||
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
|
||||
host.body.add(row);
|
||||
if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def);
|
||||
}
|
||||
}
|
||||
|
||||
function kindMark(def: PropertyDef): Label {
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
return mark;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind mark of a list doubles as the chooser for what it holds – there is no room for a
|
||||
* column of its own, and the mark is the one place that already says it. Changing it runs every
|
||||
* value through the new declaration, so a list of numbers becomes a list of texts and back.
|
||||
*/
|
||||
function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown {
|
||||
// Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron.
|
||||
const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 });
|
||||
picker.onClick = () => {
|
||||
new Menu(
|
||||
LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({
|
||||
label: LIST_ITEM_LABELS[items],
|
||||
onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)),
|
||||
})),
|
||||
).openBelow(picker);
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`).
|
||||
* Their values are edited in the List tab, one row per item, which is why there is no value column.
|
||||
*/
|
||||
function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): void {
|
||||
for (const field of def.fields ?? []) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(new Spacer(FIELD_INDENT));
|
||||
const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(
|
||||
new TextField(field.key, {
|
||||
width: 90,
|
||||
onChange: (next) =>
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
}),
|
||||
);
|
||||
host.body.add(row);
|
||||
}
|
||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
foot.minHeight = Theme.rowHeight;
|
||||
foot.add(new Spacer(FIELD_INDENT));
|
||||
foot.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
new Menu(
|
||||
FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }),
|
||||
),
|
||||
})),
|
||||
).openBelow(add);
|
||||
};
|
||||
foot.add(add);
|
||||
host.body.add(foot);
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct variable is a block rather than a row: the head renames it and adds fields, each field
|
||||
* below carries its own name, its value and a remove. That is the port editor `Pack` already has,
|
||||
@@ -264,11 +369,11 @@ function buildStructVariable(
|
||||
head.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = FIELD_KINDS.map((kind) => ({
|
||||
const items = STRUCT_FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
|
||||
new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind)),
|
||||
),
|
||||
}));
|
||||
new Menu(items).openBelow(add);
|
||||
@@ -284,7 +389,12 @@ function buildStructVariable(
|
||||
host.body.add(head);
|
||||
|
||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key));
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
|
||||
kind: 'asset',
|
||||
assetId: asset.id,
|
||||
key: def.key,
|
||||
field,
|
||||
}));
|
||||
const fields = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of def.fields) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
@@ -300,12 +410,7 @@ function buildStructVariable(
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
const item = createField(
|
||||
host,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
perField(field),
|
||||
);
|
||||
const item = createField(host, fieldDef(field), structFieldValue(value, field), perField(field));
|
||||
row.add(item.widget, item.grow ? 1 : 0);
|
||||
if (!item.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import {
|
||||
emptyField,
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
@@ -199,9 +199,16 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
|
||||
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||||
|
||||
/** A struct field as a property definition – a list field becomes the same row a list property has. */
|
||||
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
||||
return field.kind === 'list'
|
||||
? { key: field.key, label, kind: 'list', default: [], items: field.items }
|
||||
: primitiveDef(field.key, field.kind, label);
|
||||
}
|
||||
|
||||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||||
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind);
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
|
||||
}
|
||||
|
||||
/** The bare widget for a property definition (no label, no reset). */
|
||||
@@ -250,12 +257,7 @@ export function createField(
|
||||
const fields = new Map<string, Field>();
|
||||
for (const field of def.fields) {
|
||||
const { row } = labelledRow(field.key);
|
||||
const sub = createField(
|
||||
ctx,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
per(field),
|
||||
);
|
||||
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
|
||||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||||
if (!sub.grow) row.add(new Spacer(), 1);
|
||||
fields.set(field.key, sub);
|
||||
|
||||
@@ -320,7 +320,13 @@ function propertyRow(
|
||||
{
|
||||
...previewingHandlers<ObjectRef>(host, overrideSpec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({
|
||||
kind: 'object',
|
||||
holderId,
|
||||
objectId,
|
||||
key: def.key,
|
||||
field,
|
||||
})),
|
||||
},
|
||||
{
|
||||
extra: aspectActions(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
@@ -31,23 +31,30 @@ export function structFields<Target>(
|
||||
host: { state: EditorState },
|
||||
def: PropertyDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {};
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {};
|
||||
}
|
||||
|
||||
/** `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. */
|
||||
export function structFieldsOf<Target>(
|
||||
host: { state: EditorState },
|
||||
def: StructDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): (field: StructFieldDef) => FieldHandlers {
|
||||
return (field) =>
|
||||
previewingHandlers<Target>(host, {
|
||||
return (field) => ({
|
||||
...previewingHandlers<Target>(host, {
|
||||
...spec,
|
||||
toModel: (input, target) => ({
|
||||
...structOf(def.fields, spec.read(target)),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
});
|
||||
}),
|
||||
...(field.kind === 'list' && fieldSlot
|
||||
? { openList: () => host.state.openList(fieldSlot(field.key)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an inspector row finds the value it edits, and what committing it means. */
|
||||
|
||||
+93
-39
@@ -2,23 +2,24 @@
|
||||
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
|
||||
* three cases is the spec the matching inspector section already uses, so an item edited in the
|
||||
* List tab goes through the same command, the same coercion and the same undo step as the row
|
||||
* it replaces.
|
||||
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
|
||||
* level deeper, and the writes still go through the owner's command as a whole bundle.
|
||||
*/
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findNode, type GraphNode } from '../../core/graph/Graph';
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
@@ -43,19 +44,24 @@ export interface ListAccess {
|
||||
replace(next: ListValue): void;
|
||||
}
|
||||
|
||||
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
|
||||
interface Owner<Target> {
|
||||
def: PropertyDef;
|
||||
/** Its effective value, re-read on every call – a preview writes into the model. */
|
||||
current: () => PropertyValue | undefined;
|
||||
title: string;
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>;
|
||||
}
|
||||
|
||||
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
||||
if (slot.kind === 'asset') return assetAccess(state, slot);
|
||||
if (slot.kind === 'object') return objectAccess(state, slot);
|
||||
return nodeAccess(state, slot);
|
||||
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
|
||||
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
|
||||
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
||||
return inSlot(nodeOwner(state, slot));
|
||||
}
|
||||
|
||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||
|
||||
const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
|
||||
const def = schema.find((d) => d.key === key);
|
||||
return def?.kind === 'list' ? def : null;
|
||||
};
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
|
||||
list.map((value, i) => (i === index ? item : value));
|
||||
|
||||
@@ -63,6 +69,41 @@ const withItem = (list: ListValue, index: number, item: number | string | boolea
|
||||
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
|
||||
structOf(def.fields ?? [], list[index]);
|
||||
|
||||
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
|
||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
|
||||
if (field === undefined)
|
||||
return owner.def.kind === 'list'
|
||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
|
||||
: null;
|
||||
if (owner.def.kind !== 'struct') return null;
|
||||
const declared = owner.def.fields.find((f) => f.key === field);
|
||||
if (declared?.kind !== 'list') return null;
|
||||
const fields = owner.def.fields;
|
||||
const read = (target: Target) => {
|
||||
const bundle = owner.spec.read(target);
|
||||
return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]);
|
||||
};
|
||||
// Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the
|
||||
// list alone would drop its neighbours, and a snapshot would undo an edit made in between.
|
||||
const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({
|
||||
...structOf(fields, target === undefined ? undefined : owner.spec.read(target)),
|
||||
[field]: asList(list),
|
||||
});
|
||||
return access<Target>(
|
||||
state,
|
||||
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
|
||||
`${owner.title} · ${field}`,
|
||||
() => asList(structOf(fields, owner.current())[field]),
|
||||
{
|
||||
target: owner.spec.target,
|
||||
read,
|
||||
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
|
||||
remove: owner.spec.remove,
|
||||
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
|
||||
* the next keystroke has to build on what is in there now, and the commit on what the restore
|
||||
@@ -99,7 +140,7 @@ function access<Target>(
|
||||
toModel: (input) =>
|
||||
withItem(current(), index, {
|
||||
...structItemAt(current(), index, def),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -112,67 +153,80 @@ function access<Target>(
|
||||
};
|
||||
}
|
||||
|
||||
function assetAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): ListAccess | null {
|
||||
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
|
||||
const asset = findAsset(state.project, slot.assetId);
|
||||
const def = asset && listDefIn(schemaForAsset(asset), slot.key);
|
||||
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
|
||||
if (!asset || !def) return null;
|
||||
const at = () => findAsset(state.project, slot.assetId);
|
||||
return access<Asset>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${asset.name} · ${def.label}`,
|
||||
() => {
|
||||
title: `${asset.name} · ${def.label}`,
|
||||
current: () => {
|
||||
const now = at();
|
||||
return now ? asList(resolveAssetProps(now)[slot.key]) : [];
|
||||
return now ? resolveAssetProps(now)[slot.key] : undefined;
|
||||
},
|
||||
{
|
||||
spec: {
|
||||
target: at,
|
||||
read: (found) => found.props[slot.key],
|
||||
write: (found, value) => (found.props[slot.key] = value),
|
||||
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function objectAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'object' }>): ListAccess | null {
|
||||
function objectOwner(
|
||||
state: EditorState,
|
||||
slot: Extract<ListSlot, { kind: 'object' }>,
|
||||
): Owner<ObjectRef> | null {
|
||||
const ref = currentRef(state, slot.holderId, slot.objectId);
|
||||
const owner = ref && findAsset(state.project, ref.object.assetId);
|
||||
const def = owner && listDefIn(schemaForAsset(owner), slot.key);
|
||||
const def = owner && schemaForAsset(owner).find((d) => d.key === slot.key);
|
||||
if (!ref || !def) return null;
|
||||
return access<ObjectRef>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${ref.object.name} · ${def.label}`,
|
||||
() => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]),
|
||||
{
|
||||
title: `${ref.object.name} · ${def.label}`,
|
||||
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
|
||||
spec: {
|
||||
target: () => currentRef(state, slot.holderId, slot.objectId),
|
||||
read: (found) => found.object.overrides[slot.key],
|
||||
write: (found, value) => found.setOverride(slot.key, value),
|
||||
remove: (found) => found.setOverride(slot.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): ListAccess | null {
|
||||
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
|
||||
const found = nodeIn(state, slot);
|
||||
if (!found) return null;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const def = nodeDef && listDefIn(nodeDef.params, slot.key);
|
||||
if (!def) return null;
|
||||
return access<GraphNode>(
|
||||
state,
|
||||
const def =
|
||||
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
|
||||
if (!nodeDef || !def) return null;
|
||||
return {
|
||||
def,
|
||||
`${nodeDef.label} · ${def.label}`,
|
||||
() => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default),
|
||||
{
|
||||
title: `${nodeDef.label} · ${def.label}`,
|
||||
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
|
||||
spec: {
|
||||
target: () => nodeIn(state, slot)?.node,
|
||||
read: (node) => node.params[slot.key],
|
||||
write: (node, value) => (node.params[slot.key] = value),
|
||||
remove: (node) => delete node.params[slot.key],
|
||||
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
|
||||
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
|
||||
* something the node no longer reads.
|
||||
*/
|
||||
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
|
||||
const port = inlineInputs(def).find((p) => p.key === key);
|
||||
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
|
||||
const inline = inlineInputDef(port);
|
||||
return inline.kind === 'list' ? inline : null;
|
||||
}
|
||||
|
||||
/** The node together with its graph, because `resolveDef` needs both. */
|
||||
|
||||
+6
-2
@@ -29,14 +29,18 @@ export type Selection =
|
||||
/**
|
||||
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
|
||||
* selects, it is a value inside something already selected, and exactly three places hold one.
|
||||
* `field` names a list held by a struct field one level down; a struct item of a list needs no
|
||||
* such step, because the List tab renders it inline.
|
||||
*/
|
||||
export type ListSlot =
|
||||
export type ListSlot = { field?: string } & (
|
||||
| { kind: 'asset'; assetId: string; key: string }
|
||||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string };
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||||
);
|
||||
|
||||
export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
if (a.field !== b.field) return false;
|
||||
switch (a.kind) {
|
||||
case 'asset':
|
||||
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* What a list variable holds, chosen in the editor rather than only in a project file. Two things
|
||||
* matter: changing the item kind drags every value along (declared default, own value, overrides),
|
||||
* and a list of structs declares its item shape through the same field commands a struct does.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
RemoveStructFieldCommand,
|
||||
RenameStructFieldCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
const findObject = (project: Project, id: string) =>
|
||||
objectHolders(project)
|
||||
.flatMap((holder) => holder.objects)
|
||||
.find((o) => o.id === id);
|
||||
|
||||
const listDef = (project: Project, assetId: string, key: string) => {
|
||||
const asset = findAsset(project, assetId);
|
||||
const def = asset?.type === 'behavior' ? asset.variables.find((v) => v.key === key) : undefined;
|
||||
return def?.kind === 'list' ? def : undefined;
|
||||
};
|
||||
|
||||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||||
function withList() {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'stops', 'list'));
|
||||
const asset = findAsset(f.project, behavior.id)!;
|
||||
asset.props.stops = [1, 2];
|
||||
const object = place(f, behavior.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'stops', [3]));
|
||||
return { f, behaviorId: behavior.id, object };
|
||||
}
|
||||
|
||||
describe('what a list variable holds', () => {
|
||||
it('starts on numbers, the one kind `+ Variable ▸ List` can pick', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
|
||||
});
|
||||
|
||||
it('takes every value with it when the kind changes, and gives them back on undo', () => {
|
||||
const { f, behaviorId, object } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'text'));
|
||||
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('text');
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual(['1', '2']);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual(['3']);
|
||||
|
||||
f.stack.undo();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.items).toBe('number');
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([1, 2]);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual([3]);
|
||||
});
|
||||
|
||||
/** Bare numbers have no shape, so they become the empty item rather than disappearing. */
|
||||
it('turns loose items into the empty shape when it becomes a list of structs', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([]);
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
|
||||
});
|
||||
|
||||
it('refuses a change to the kind it already holds', () => {
|
||||
const { f, behaviorId } = withList();
|
||||
expect(() => f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'number'))).toThrow(
|
||||
/already holds/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the item shape of a list of structs', () => {
|
||||
function withStructList() {
|
||||
const { f, behaviorId, object } = withList();
|
||||
f.stack.execute(new SetListItemsCommand(behaviorId, 'stops', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'x', kind: 'number' }));
|
||||
return { f, behaviorId, object };
|
||||
}
|
||||
|
||||
it('grows a field in the declaration and in every item of every value at once', () => {
|
||||
const { f, behaviorId, object } = withStructList();
|
||||
expect(listDef(f.project, behaviorId, 'stops')?.fields).toEqual([{ key: 'x', kind: 'number' }]);
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{ x: 0 }, { x: 0 }]);
|
||||
expect(findObject(f.project, object)?.overrides.stops).toEqual([{ x: 0 }]);
|
||||
|
||||
f.stack.undo();
|
||||
expect(findAsset(f.project, behaviorId)?.props.stops).toEqual([{}, {}]);
|
||||
});
|
||||
|
||||
it('keeps the values of the items it does not touch when one is renamed or removed', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
const asset = findAsset(f.project, behaviorId)!;
|
||||
asset.props.stops = [{ x: 5 }, { x: 6 }];
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', { key: 'y', kind: 'number' }));
|
||||
f.stack.execute(new RenameStructFieldCommand(behaviorId, 'stops', 'x', 'col'));
|
||||
expect(asset.props.stops).toEqual([
|
||||
{ col: 5, y: 0 },
|
||||
{ col: 6, y: 0 },
|
||||
]);
|
||||
|
||||
f.stack.execute(new RemoveStructFieldCommand(behaviorId, 'stops', 'y'));
|
||||
expect(asset.props.stops).toEqual([{ col: 5 }, { col: 6 }]);
|
||||
|
||||
f.stack.undo();
|
||||
expect(asset.props.stops).toEqual([
|
||||
{ col: 5, y: 0 },
|
||||
{ col: 6, y: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
/** `list → struct → list → primitive` is one level deeper than anything the model stores. */
|
||||
it('refuses a list field, which a struct variable does allow', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
const listField = { key: 'inner', kind: 'list', items: 'number' } as const;
|
||||
expect(() => f.stack.execute(new AddStructFieldCommand(behaviorId, 'stops', listField))).toThrow(
|
||||
/cannot sit in the items of a list/,
|
||||
);
|
||||
|
||||
f.stack.execute(new AddVariableCommand(behaviorId, 'route', 'struct'));
|
||||
f.stack.execute(new AddStructFieldCommand(behaviorId, 'route', listField));
|
||||
expect(findAsset(f.project, behaviorId)?.props.route).toEqual({ inner: [] });
|
||||
});
|
||||
|
||||
it('survives being written out and read back in', () => {
|
||||
const { f, behaviorId } = withStructList();
|
||||
findAsset(f.project, behaviorId)!.props.stops = [{ x: 5 }, { x: 6 }];
|
||||
const back = roundTrip(f.project);
|
||||
expect(listDef(back, behaviorId, 'stops')).toMatchObject({
|
||||
items: 'struct',
|
||||
fields: [{ key: 'x', kind: 'number' }],
|
||||
});
|
||||
expect(findAsset(back, behaviorId)?.props.stops).toEqual([{ x: 5 }, { x: 6 }]);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,9 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||
import { variableDef } from '../../src/core/properties/PropertySchema';
|
||||
@@ -123,6 +124,46 @@ describe('the address a list is edited through', () => {
|
||||
expect(resolveList(f.state, words)!.value).toEqual(['red', 'blue']);
|
||||
});
|
||||
|
||||
/**
|
||||
* An unconnected `list` input carries its inline value in the same params bag a setting does,
|
||||
* so it is the same slot – before this it offered a number field instead.
|
||||
*/
|
||||
it('reaches the inline value of an unconnected list input', () => {
|
||||
const add = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: add.node.id, key: 'list' };
|
||||
expect(resolveList(f.state, port)!.def.items).toBe('number');
|
||||
expect(resolveList(f.state, port)!.title).toBe('Count · list');
|
||||
|
||||
resolveList(f.state, port)!.replace([1, 2, 3]);
|
||||
resolveList(f.state, port)!.itemHandlers(1).onChange('9');
|
||||
expect(resolveList(f.state, port)!.value).toEqual([1, 9, 3]);
|
||||
|
||||
f.state.commands.undo();
|
||||
expect(resolveList(f.state, port)!.value).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
/** A fed port ignores its inline value, so editing it would be editing nothing. */
|
||||
it('stops resolving a list input once an edge feeds it', () => {
|
||||
const source = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
|
||||
const sink = new AddNodeCommand(f.behavior.id, 'Count', 0, 0);
|
||||
expect(f.state.execute(source)).toBe(true);
|
||||
expect(f.state.execute(sink)).toBe(true);
|
||||
const port: ListSlot = { kind: 'node', assetId: f.behavior.id, nodeId: sink.node.id, key: 'list' };
|
||||
expect(resolveList(f.state, port)).not.toBe(null);
|
||||
|
||||
expect(
|
||||
f.state.execute(
|
||||
new ConnectCommand(
|
||||
f.behavior.id,
|
||||
{ node: source.node.id, port: 'value' },
|
||||
{ node: sink.node.id, port: 'list' },
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(resolveList(f.state, port)).toBe(null);
|
||||
});
|
||||
|
||||
it('reaches a node setting too, which is the other place a list lives', () => {
|
||||
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
@@ -135,3 +176,85 @@ describe('the address a list is edited through', () => {
|
||||
expect(resolveList(f.state, node)!.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A list one level down, inside a struct field. The bundle is what gets written, so the point of
|
||||
* every check here is that the neighbouring fields survive an edit to the list.
|
||||
*/
|
||||
describe('a list a struct field holds', () => {
|
||||
let f: ReturnType<typeof setup>;
|
||||
let slot: ListSlot;
|
||||
|
||||
beforeEach(() => {
|
||||
f = setup();
|
||||
const id = f.behavior.id;
|
||||
expect(f.state.execute(new AddVariableCommand(id, 'route', 'struct'))).toBe(true);
|
||||
expect(f.state.execute(new AddStructFieldCommand(id, 'route', { key: 'name', kind: 'text' }))).toBe(true);
|
||||
expect(
|
||||
f.state.execute(
|
||||
new AddStructFieldCommand(id, 'route', { key: 'stops', kind: 'list', items: 'number' }),
|
||||
),
|
||||
).toBe(true);
|
||||
slot = { kind: 'asset', assetId: id, key: 'route', field: 'stops' };
|
||||
});
|
||||
|
||||
const list = () => resolveList(f.state, slot)!;
|
||||
const bundle = () => (findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route;
|
||||
|
||||
it('starts empty and says where it sits', () => {
|
||||
expect(list().value).toEqual([]);
|
||||
expect(list().def.items).toBe('number');
|
||||
expect(list().title).toBe('B · route · stops');
|
||||
});
|
||||
|
||||
it('writes the whole bundle, so the neighbouring field keeps its value', () => {
|
||||
resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'route' });
|
||||
(findAsset(f.state.project, f.behavior.id) as BehaviorAsset).props.route = { name: 'north', stops: [] };
|
||||
|
||||
list().replace([1, 2, 3]);
|
||||
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
|
||||
|
||||
list().itemHandlers(1).onChange('9');
|
||||
expect(bundle()).toEqual({ name: 'north', stops: [1, 9, 3] });
|
||||
|
||||
f.state.commands.undo();
|
||||
expect(bundle()).toEqual({ name: 'north', stops: [1, 2, 3] });
|
||||
});
|
||||
|
||||
/** A preview writes past the command, so mid-edit the bundle has to stay whole as well. */
|
||||
it('keeps the bundle whole mid-preview', () => {
|
||||
list().replace([5]);
|
||||
list().itemHandlers(0).onInput('7');
|
||||
expect(bundle()).toEqual({ name: '', stops: [7] });
|
||||
});
|
||||
|
||||
it('is nothing at all for a field that is not a list, or is not there', () => {
|
||||
expect(resolveList(f.state, { ...slot, field: 'name' })).toBe(null);
|
||||
expect(resolveList(f.state, { ...slot, field: 'no-such' })).toBe(null);
|
||||
expect(resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums', field: 'x' })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('survives being written out and read back in', () => {
|
||||
list().replace([4, 5]);
|
||||
const back = deserializeProject(serializeProject(f.state.project));
|
||||
const asset = findAsset(back, f.behavior.id) as BehaviorAsset;
|
||||
expect(asset.variables.find((v) => v.key === 'route')).toMatchObject({
|
||||
fields: [
|
||||
{ key: 'name', kind: 'text' },
|
||||
{ key: 'stops', kind: 'list', items: 'number' },
|
||||
],
|
||||
});
|
||||
expect(asset.props.route).toEqual({ name: '', stops: [4, 5] });
|
||||
});
|
||||
|
||||
/** The one shape the model does not store: a list inside a struct inside a list. */
|
||||
it('is refused inside the struct items of a list', () => {
|
||||
const def = variableDef('rows', 'list', [], 'struct', [
|
||||
{ key: 'x', kind: 'number' },
|
||||
{ key: 'inner', kind: 'list', items: 'number' },
|
||||
]);
|
||||
expect(def.kind === 'list' && def.fields).toEqual([{ key: 'x', kind: 'number' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
# Restposten aus dynamischen Ports / Structs
|
||||
|
||||
Drei Punkte, die beim Bau von List-Tab und Struct-Variablen bewusst liegen geblieben sind. Sie
|
||||
standen am Ende von `done/nodes-mit-dynamischer-port-zahl.md` und sind hierher gezogen, damit sie
|
||||
nicht mit der erledigten Notiz verschwinden.
|
||||
|
||||
**Alle drei sind gebaut** — dazu der Nebenpunkt, der beim Bauen aufgefallen war.
|
||||
|
||||
## 1. Der unverbundene Eingang zeigt das falsche Feld — ERLEDIGT
|
||||
|
||||
`inlineInputDef` (`NodeRegistry.ts:131`) kannte nur `boolean`, `string`/`any` und *alles andere* —
|
||||
und "alles andere" wurde ein **Zahlenfeld**.
|
||||
|
||||
Beim Nachmessen war der Restposten **kleiner als die Notiz behauptete**: `object` und `struct`
|
||||
kommen gar nicht mehr an `inlineInputDef` vorbei, `inlineInputs` siebt sie schon aus (mit der
|
||||
Begründung, die die Notiz für `object` verlangt hat: ein Objekt ist ein lebendes Handle, ein
|
||||
Struct wird aus Teilen gebaut). Offen war nur `list` — und dort ist es genau die Antwort geworden,
|
||||
die die Notiz vorgezeichnet hat: der Knopf `n items` plus List-Tab.
|
||||
|
||||
Zwei Dinge, die dabei zu entscheiden waren:
|
||||
|
||||
- **Die Item-Art ist `number`.** Kein Port deklariert eine — und das ist derselbe Vorgabewert, den
|
||||
`+ Variable ▸ List` setzt. Der Graph typisiert eine Liste ohnehin nur als Liste.
|
||||
- **Ein verbundener Port löst nicht mehr auf.** Sobald eine Kante den Eingang speist, ist der
|
||||
Inline-Wert tot; der Slot liefert `null`, statt etwas zu bearbeiten, das der Node nicht liest.
|
||||
|
||||
## 2. Ein Struct-Feld vom Typ Liste — ERLEDIGT
|
||||
|
||||
Es blieb bei einer Signaturänderung, kein Umbau — aber die Naht war nicht `resolveList` allein,
|
||||
sondern der **Besitzer** dahinter. Die drei Fälle (Asset, Objekt, Node) liefern jetzt nur noch,
|
||||
*was an `key` steht und wie man es liest und schreibt*; ob daraus die Liste selbst oder die Liste
|
||||
eines Struct-Feldes wird, entscheidet ein Schritt danach. `ListSlot` trägt dafür ein optionales
|
||||
`field`.
|
||||
|
||||
**Die Tiefenregel steht wieder in der Deklaration, nicht in einem Kommentar:** `StructFieldKind`
|
||||
(= Primitive + `list`) sagt, was ein Struct-**Feld** hält, und ein solches Listenfeld hält
|
||||
ausschließlich **Primitive** (`ListFieldDef`). Genau diese eine Asymmetrie hält die speicherbaren
|
||||
Formen endlich:
|
||||
|
||||
```
|
||||
list → struct → primitiv
|
||||
struct → list → primitiv
|
||||
```
|
||||
|
||||
Tiefer geht nichts — und das ist buchstäblich das, was `asStoredValue` seit jeher durch einen
|
||||
untypisierten Slot lässt. Ein Listenfeld in den Items einer Liste weist `AddStructFieldCommand`
|
||||
deshalb ab.
|
||||
|
||||
Geschrieben wird immer das **ganze Bündel**, neu gebaut aus dem, was in dem Moment im Besitzer
|
||||
steht: nur die Liste zu schreiben würde ihre Nachbarfelder verlieren, ein Schnappschuss würde eine
|
||||
zwischenzeitliche Änderung zurücknehmen.
|
||||
|
||||
## 3. Liste aus Structs — ERLEDIGT
|
||||
|
||||
Gebaut. Zwei Annahmen dieser Notiz waren dabei **falsch**, und beide haben es billiger gemacht:
|
||||
|
||||
- Es wird **nichts gegenseitig rekursiv**. Die Tiefe steckt in der *Deklaration*, nicht in einer
|
||||
Konvention (siehe Punkt 2).
|
||||
- Es setzt **Punkt 2 nicht voraus**. Ein Struct-Item braucht keinen Pfad in der Adresse, weil es
|
||||
**inline** gerendert wird — dieselbe Begründung, aus der ein Struct im Inspector ein Block ist
|
||||
und keine eigene Navigation bekommt. `structFieldHandlers` trägt die Feld-Adresse.
|
||||
|
||||
## Nebenpunkt: die Item-Art war im Editor nicht wählbar — ERLEDIGT
|
||||
|
||||
`+ Variable ▸ List` legte immer `items: 'number'` an, und für `items: 'struct'` gab es keinen
|
||||
Feld-Editor; eine Liste aus Structs konnte deshalb nur aus einer Projektdatei entstehen.
|
||||
|
||||
- **Die Kennmarke ist der Wähler.** `≡#`, `≡Aa`, `≡✓`, `≡◧`, `≡⊞` — eine eigene Spalte gibt die
|
||||
300 px breite Leiste nicht her, und die Marke ist die eine Stelle, die es sowieso schon sagt.
|
||||
- **Ein Wechsel zieht jeden Wert mit** (`SetListItemsCommand`): deklarierter Vorgabewert, eigener
|
||||
Wert des Behaviors und **jede Instanz-Abweichung**, jeweils durch `coerceValue` gegen die neue
|
||||
Deklaration — dieselbe Umwandlung, die ein im List-Tab bearbeiteter Posten durchläuft.
|
||||
- **Eine Liste aus Structs deklariert ihre Item-Form mit demselben `+ Field`**, das eine
|
||||
Struct-Variable hat — nur Namen und Arten, weil die Werte Zeile für Zeile im Tab bearbeitet
|
||||
werden. Die Feld-Kommandos gelten dafür jetzt auch für den Listen-Besitzer und fassen **jedes
|
||||
Item jedes Wertes** an, damit kein Wert eine Form behält, die seine Deklaration nicht mehr
|
||||
verspricht.
|
||||
@@ -0,0 +1,3 @@
|
||||
beim graph bräuchte ich einen toggle der grid snapping aktiviert.
|
||||
alle vorhandenen nodes soll da bleiben wo sie sind, erst wenn man ein node bewegt soll es greifen
|
||||
die grid-größe kannst du selbst entscheiden, am besten ein sinnvoller wert
|
||||
@@ -1,41 +0,0 @@
|
||||
# Restposten aus dynamischen Ports / Structs
|
||||
|
||||
Drei Punkte, die beim Bau von List-Tab und Struct-Variablen bewusst liegen geblieben sind. Sie
|
||||
standen am Ende von `done/nodes-mit-dynamischer-port-zahl.md` und sind hierher gezogen, damit sie
|
||||
nicht mit der erledigten Notiz verschwinden.
|
||||
|
||||
**Punkt 3 ist inzwischen gebaut** (siehe unten). Offen sind noch 1 und 2.
|
||||
|
||||
Fehlender Nebenpunkt, der dabei aufgefallen ist: eine Listen-Variable kann ihre **Item-Art im
|
||||
Editor gar nicht wählen** — `+ Variable ▸ List` legt immer `items: 'number'` an, und für
|
||||
`items: 'struct'` gibt es keinen Feld-Editor. Eine Liste aus Structs entsteht deshalb heute nur
|
||||
aus einer Projektdatei (Seeder); bearbeiten lässt sie sich danach normal.
|
||||
|
||||
## 1. Der unverbundene Eingang zeigt das falsche Feld
|
||||
|
||||
`inlineInputDef` (`NodeRegistry.ts:131`) kennt nur `boolean`, `string`/`any` und *alles andere* —
|
||||
und "alles andere" wird ein **Zahlenfeld**. Betroffen sind damit **`list`, `struct` und `object`**:
|
||||
ein unverbundener `list`-Eingang bietet heute ein Zahlenfeld an.
|
||||
|
||||
Für `list` gäbe es die Antwort schon: der Knopf `n items` plus List-Tab. Der Weg dorthin ist
|
||||
`listOpener` mit einer Slot-Adresse vom Kind `node` — der Slot existiert bereits. Für `object`
|
||||
ist gar kein Inline-Wert sinnvoll; dort gehört das Feld weggelassen statt ersetzt.
|
||||
|
||||
## 2. Ein Struct-Feld vom Typ Liste
|
||||
|
||||
Ein deklariertes Struct-Feld ist heute ein Primitiv (Zahl, Text, Ja/Nein, Farbe), obwohl
|
||||
`FieldValue` auch Listen zulässt. Ein Listen-Feld bräuchte einen Editor eine Ebene tiefer, also
|
||||
einen **Pfad in der Slot-Adresse** (`ListSlot` in `state.ts` hat bewusst keinen). Die Naht ist die
|
||||
eine Funktion `resolveList` — eine Signaturänderung, kein Umbau.
|
||||
|
||||
## 3. Liste aus Structs — ERLEDIGT
|
||||
|
||||
Gebaut. Zwei Annahmen dieser Notiz waren dabei **falsch**, und beide haben es billiger gemacht:
|
||||
|
||||
- Es wird **nichts gegenseitig rekursiv**. Die Tiefe steckt in der *Deklaration*, nicht in einer
|
||||
Konvention: `FieldKind` (Primitive) sagt, was ein Struct-**Feld** hält, `ListItemKind`
|
||||
(= `FieldKind` + `struct`) sagt, was eine **Liste** hält. Damit ist `list → struct → primitiv`
|
||||
die tiefste gespeicherte Form — kein beliebiges Schachteln, `sameValue` bleibt endlich.
|
||||
- Es setzt **Punkt 2 nicht voraus**. Ein Struct-Item braucht keinen Pfad in der Adresse, weil es
|
||||
**inline** gerendert wird — dieselbe Begründung, aus der ein Struct im Inspector ein Block ist
|
||||
und keine eigene Navigation bekommt. `structFieldHandlers` trägt die Feld-Adresse.
|
||||
@@ -95,6 +95,34 @@ export async function listTab(t) {
|
||||
await sleep(200);
|
||||
check('and the count follows the value', (await find('2 items')) !== null);
|
||||
|
||||
// 6b. What a list holds is a choice in the inspector, not only in a project file: the kind mark
|
||||
// doubles as the picker, and changing it runs every value through the new declaration.
|
||||
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
|
||||
await clickText('\u2261#');
|
||||
await sleep(150);
|
||||
await clickText('Texts');
|
||||
await sleep(250);
|
||||
let listy = await numsOf();
|
||||
check(
|
||||
'the kind mark picks what the list holds',
|
||||
listy.variables[0].items === 'text',
|
||||
listy.variables[0].items,
|
||||
);
|
||||
check('and the mark itself says what it holds now', (await find('≡Aa')) !== null);
|
||||
check(
|
||||
'and every value follows the declaration',
|
||||
JSON.stringify(listy.props.nums) === '["1","2"]',
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
await undo();
|
||||
await sleep(250);
|
||||
listy = await numsOf();
|
||||
check(
|
||||
'undo puts the kind and the values back',
|
||||
listy.variables[0].items === 'number' && JSON.stringify(listy.props.nums) === '[1,2]',
|
||||
JSON.stringify(listy.props.nums),
|
||||
);
|
||||
|
||||
// 7. A list whose target is gone empties the tab rather than showing a stale one.
|
||||
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
|
||||
await sleep(200);
|
||||
@@ -176,10 +204,73 @@ export async function listTab(t) {
|
||||
JSON.stringify(back),
|
||||
);
|
||||
|
||||
// 8b. The item shape is declared in the inspector as well, with the control a struct has – and
|
||||
// primitives only, because a list inside a list item is the one shape the model does not store.
|
||||
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
|
||||
check('a list of structs carries the add-a-field control', (await find('+ Field')) !== null);
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
await clickText('Boolean');
|
||||
await sleep(250);
|
||||
check(
|
||||
'+ Field grows every item of the list at once',
|
||||
JSON.stringify(await cellsOf()) ===
|
||||
JSON.stringify([
|
||||
{ x: 30, y: 11, field1: false },
|
||||
{ x: 44, y: 55, field1: false },
|
||||
]),
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
info = await json('__gm.list.info()');
|
||||
check(
|
||||
'and the tab shows the new field on every row',
|
||||
info.items[0] === 'x: 30, y: 11, field1: false',
|
||||
info.items[0],
|
||||
);
|
||||
await shot('06-item-field');
|
||||
await undo();
|
||||
await sleep(250);
|
||||
check(
|
||||
'undo takes it out of every item again',
|
||||
JSON.stringify(await cellsOf()) ===
|
||||
JSON.stringify([
|
||||
{ x: 30, y: 11 },
|
||||
{ x: 44, y: 55 },
|
||||
]),
|
||||
JSON.stringify(await cellsOf()),
|
||||
);
|
||||
|
||||
// 9. A node's unconnected `list` input carries its value in the very same params bag a setting
|
||||
// does, so it is a count button too – before this it offered a number field.
|
||||
await mutate(`
|
||||
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
|
||||
a.graph.nodes.push({ id: 'counter', type: 'Count', x: 40, y: 40, params: {} });
|
||||
__gm.showTab('graph');
|
||||
__gm.state.select({ kind: 'node', assetId: 'celly', nodeId: 'counter', nodeIds: ['counter'] });
|
||||
__gm.state.preview();
|
||||
`);
|
||||
await sleep(300);
|
||||
const portButton = await find('0 items');
|
||||
check('an unconnected list input reads as an item count', portButton !== null);
|
||||
await click(portButton.x, portButton.y);
|
||||
await sleep(250);
|
||||
check('clicking it opens the List tab on the port', (await json('__gm.tab()')) === 'list');
|
||||
info = await json('__gm.list.info()');
|
||||
check('titled by the node and the port it belongs to', info.title === 'Count · list', info.title);
|
||||
await clickText('+ Item');
|
||||
await sleep(250);
|
||||
const params = await json(`__gm.state.project.assets.find((a) => a.id === 'celly').graph.nodes[0].params`);
|
||||
check(
|
||||
'and the item lands in the node params, where the node reads it',
|
||||
JSON.stringify(params) === '{"list":[0]}',
|
||||
JSON.stringify(params),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== 'celly');
|
||||
__gm.state.select(null);
|
||||
__gm.showTab('scene');
|
||||
__gm.state.preview();
|
||||
`);
|
||||
await sleep(200);
|
||||
|
||||
@@ -94,6 +94,47 @@ export async function structVariable(t) {
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
|
||||
// 6. A field may itself be a list. Its row is the same "n items" button a list property has,
|
||||
// and it opens the List tab one level down – on the field, not on the whole bundle.
|
||||
await clickText('+ Field');
|
||||
await sleep(150);
|
||||
// "List" is also the tab bar's own label, so the menu entry is reached as the last drawn one.
|
||||
const listOption = await json(`__gm.locate('List', true)`);
|
||||
await click(listOption.x, listOption.y);
|
||||
await sleep(250);
|
||||
check(
|
||||
'+ Field ▸ List gives the bundle an empty list',
|
||||
Array.isArray((await piece()).field1),
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
const itemsButton = await find('0 items');
|
||||
check('and that field reads as an item count', itemsButton !== null);
|
||||
|
||||
await click(itemsButton.x, itemsButton.y);
|
||||
await sleep(250);
|
||||
check('clicking it opens the List tab', (await json('__gm.tab()')) === 'list');
|
||||
const info = await json('__gm.list.info()');
|
||||
check('whose title names the field inside the bundle', info.title.endsWith('· field1'), info.title);
|
||||
|
||||
await clickText('+ Item');
|
||||
await sleep(250);
|
||||
check(
|
||||
'and an item added there leaves the neighbouring fields alone',
|
||||
JSON.stringify(await piece()) === '{"column":30,"tag":"tee","field1":[0]}',
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
await shot('03-list-field');
|
||||
await undo();
|
||||
await undo();
|
||||
await sleep(200);
|
||||
check(
|
||||
'undo takes the list field back out of the bundle',
|
||||
!('field1' in (await piece())),
|
||||
JSON.stringify(await piece()),
|
||||
);
|
||||
|
||||
await mutate('__gm.state.openList(null)');
|
||||
await sleep(200);
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => a.id !== 'structy');
|
||||
|
||||
Reference in new issue
Block a user