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:
rootandClaude Opus 5 committed 2026-09-10 16:36:15 +02:00
1 parent fe3c63c48c
commit 10d030376a
18 files changed
+984 -184

No files matched your search

+17 -7
View File
@@ -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
+171 -42
View File
@@ -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;
}
+11 -1
View File
@@ -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,
+38 -6
View File
@@ -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);
+10 -2
View File
@@ -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>();
+132 -27
View File
@@ -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(
+10 -8
View File
@@ -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);
+7 -1
View File
@@ -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(
+13 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+139
View File
@@ -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 }]);
});
});
+125 -2
View File
@@ -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.
+3
View File
@@ -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.
+91
View File
@@ -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);
+41
View File
@@ -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');