Drop the per-list item declaration; every item carries its own kind

A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.

List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.

Legacy lists that declared items: 'color' are tagged on load.

Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.

Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.

Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 13:35:02 +02:00
1 parent e147dd6d21
commit 11c900c998
34 files changed
+1218 -640

No files matched your search

+3
View File
@@ -260,6 +260,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
info: () => editor.listEditor.debugInfo(),
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
/** The kind mark of a row, or of one field of a struct item – both are pickers. */
markAt: (index: number, field = '') => editor.listEditor.markAt(index, field),
nameAt: (index: number, field: string) => editor.listEditor.nameAt(index, field),
},
world: () => editor.worldSnapshot(),
playInfo: () => editor.playInfo(),
+5 -13
View File
@@ -4,7 +4,6 @@ import {
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import { structShapeOf } from '../../core/graph';
import {
AddStructFieldCommand,
AddVariableCommand,
@@ -12,7 +11,6 @@ import {
RemoveVariableCommand,
RenameStructFieldCommand,
RenameVariableCommand,
SetListItemsCommand,
} from '../../core/commands/variableCommands';
import type { Command } from '../../core/commands/Command';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
@@ -34,9 +32,7 @@ import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
/** 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 newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, kind });
/**
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a
@@ -48,14 +44,11 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef): string {
const fields = (structShapeOf(def) ?? []).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})` : ''}`;
// Only a struct has rows of its own; a list is one row whatever its items are.
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
}
/** The fields a struct declares, or the item shape of a list of structs. */
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
const asset = findAsset(host.state.project, assetId);
@@ -164,7 +157,6 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const edit: VariableEditing = {
rename: (next) => run(new RenameVariableCommand(asset.id, def.key, next)),
remove: () => run(new RemoveVariableCommand(asset.id, def.key)),
setItems: (items) => run(new SetListItemsCommand(asset.id, def.key, items)),
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
addField: (kind) =>
@@ -172,7 +164,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(structShapeOf(def) ?? []), kind),
newField(nextFieldName(def.kind === 'struct' ? def.fields : []), kind),
),
),
};
+7 -1
View File
@@ -9,6 +9,7 @@ import {
import {
emptyFieldValue,
type FieldKind,
type ListItemKind,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
@@ -203,6 +204,11 @@ export function removeButton(onClick: () => void): Button {
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
/** The def a list item draws with: the flat kinds plus `texture`, which only a list item may be. */
export function itemDef(key: string, kind: Exclude<ListItemKind, 'struct'>, label = key): PropertyDef {
return kind === 'texture' ? { key, label, kind: 'texture', default: '' } : primitiveDef(key, kind, label);
}
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
switch (kind) {
case 'number':
@@ -221,7 +227,7 @@ 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 }
? { key: field.key, label, kind: 'list', default: [] }
: primitiveDef(field.key, field.kind, label);
}
+21 -54
View File
@@ -1,8 +1,6 @@
import type { DeclaredPortKind } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import {
FIELD_KINDS,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
STRUCT_FIELD_KINDS,
@@ -11,8 +9,18 @@ import {
type StructFieldKind,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
import type { ListDef } from '../list/slot';
import {
Button,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import {
addValue,
attachRowActions,
@@ -38,6 +46,7 @@ const KIND_MARKS: Record<ShownKind, string> = {
boolean: '✓',
text: 'Aa',
color: '◧',
texture: '▦',
list: '≡',
struct: '⊞',
object: '◇',
@@ -48,19 +57,12 @@ export const KIND_LABELS: Record<ShownKind, string> = {
boolean: 'Boolean',
text: 'Text',
color: 'Color',
texture: 'Texture',
list: 'List',
struct: 'Struct',
object: 'Object',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
number: 'Numbers',
boolean: 'Booleans',
text: 'Texts',
color: 'Colors',
struct: 'Structs',
};
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
export function kindMark(kind: string): string {
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
@@ -70,7 +72,6 @@ export function kindMark(kind: string): string {
export interface VariableEditing {
rename: (next: string) => void;
remove: () => void;
setItems: (items: ListItemKind) => void;
renameField: (field: StructFieldDef, next: string) => void;
removeField: (field: StructFieldDef) => void;
addField: (kind: StructFieldKind) => void;
@@ -95,7 +96,7 @@ export function buildVariable(
): FieldRow {
const { edit, actions } = options;
const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next)));
const head = gridRow(0, markOf(def, edit), name);
const head = gridRow(0, markLabel(kindMark(def.kind)), name);
let setValue: (next: PropertyValue) => void = () => {};
if (def.kind === 'struct') {
head.add(new Spacer(), 1);
@@ -108,7 +109,6 @@ export function buildVariable(
host.body.add(head);
if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit);
if (def.kind === 'list' && def.items === 'struct' && edit) itemShapeRows(host, def, edit);
return {
row: head,
@@ -147,24 +147,6 @@ function structFieldRows(
};
}
/**
* The item shape of a list of structs: names and kinds only (primitives, see `ListFieldDef`). The
* values are edited in the List tab, one row per item, which is why there is no value column.
*/
function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing): void {
for (const field of def.fields ?? []) {
const row = fieldRow(field, edit);
row.add(new Spacer(), 1);
row.add(removeButton(() => edit.removeField(field)));
host.body.add(row);
}
const foot = new HStack({ gap: Theme.gap, align: 'center' });
foot.minHeight = Theme.rowHeight;
foot.add(new Spacer(), 1);
foot.add(addFieldMenu(FIELD_KINDS, edit));
host.body.add(foot);
}
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
@@ -192,19 +174,14 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
}
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
if (def.kind !== 'list') return markLabel(kindMark(def.kind));
const glyph = `≡${KIND_MARKS[def.items]}`;
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
}
export function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
}
/**
* A kind mark that can be clicked. The label may take the button's whole width: the column is
* sized for its widest glyph, and the usual inset would cut `≡Aa` to `≡A…`.
* A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may
* take the button's whole width: the column is sized for its widest glyph, and the usual inset
* would cut `Aa` to `A…`.
*/
class MarkButton extends Button {
protected override labelSlot(): number {
@@ -212,20 +189,10 @@ class MarkButton extends Button {
}
}
/**
* On the asset, the mark of a list doubles as the chooser for what it holds – it is the one place
* that already says it. Changing it runs every value through the new declaration.
*/
function itemKindPicker(glyph: string, current: ListItemKind, edit: VariableEditing): Button {
/** The mark of a kind, as a button that offers the others. */
export function markPicker(glyph: string, items: MenuItem[]): Button {
const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' });
picker.onClick = () => {
new Menu(
LIST_ITEM_KINDS.filter((items) => items !== current).map((items) => ({
label: LIST_ITEM_LABELS[items],
onSelect: () => edit.setItems(items),
})),
).openBelow(picker);
};
picker.onClick = () => new Menu(items).openBelow(picker);
return picker;
}
+222 -34
View File
@@ -2,34 +2,89 @@
* Edits one list, a row per item, with the same field widgets the inspector builds -- a value that
* can be hundreds long has no room in an inspector row. The inspector stays visible beside this
* tab, so what the list belongs to is still on screen while it is edited.
*
* A list declares nothing, so this is also where an item's kind is chosen: the mark in front of a
* row is a picker, and changing it (or a struct item's shape) is an ordinary value edit.
*/
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
import {
Dropdown,
HStack,
Label,
Menu,
ScrollContainer,
Spacer,
TextField,
Theme,
VStack,
type Widget,
} from '../../ui';
import { type FieldValue, type ListValue, type PropertyValue, textOf } from '../../core/model/Properties';
import { nextFieldName } from '../../core/commands/naming';
import {
FIELD_KINDS,
type FieldKind,
LIST_ITEM_KINDS,
type ListItemKind,
} from '../../core/properties/PropertySchema';
import {
type ListItem,
itemKindOf,
itemValue,
makeFlatItem,
makeItem,
structItemFields,
structItemOf,
} from '../../core/properties/listItems';
import {
addValue,
createField,
type FieldContext,
NAME_WIDTH,
itemDef,
primitiveDef,
type PrimitiveDef,
READ_ONLY,
removeButton,
} from '../inspector/fieldFactory';
import { KIND_LABELS, kindMark, markPicker } from '../inspector/variableRows';
import type { EditorState } from '../state';
import { type ListAccess, type ListDef, resolveList } from './slot';
import { type ListAccess, resolveList } from './slot';
const INDEX_WIDTH = 40;
/** What a check script may ask a row for: its value widget, its mark, and the same per struct field. */
interface RowWidgets {
value: Widget;
mark: Widget;
parts: Map<string, Widget>;
marks: Map<string, Widget>;
names: Map<string, Widget>;
}
const rowWidgets = (mark: Widget, value: Widget): RowWidgets => ({
mark,
value,
parts: new Map(),
marks: new Map(),
names: new Map(),
});
const centreOf = (widget: Widget | undefined): { x: number; y: number } | null => {
if (!widget) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
};
export class ListEditor extends VStack {
private readonly ctx: FieldContext;
private readonly title = new Label('', { style: 'bodyBold' });
private readonly count = new Label('', { style: 'small', align: 'right' });
private readonly scroll = new ScrollContainer();
private readonly addButton: Button;
private readonly addButton: Dropdown;
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
private signature = '';
private setters: ((value: PropertyValue) => void)[] = [];
private fields: { widget: Widget; parts?: ReadonlyMap<string, Widget> }[] = [];
private setters: ((item: PropertyValue) => void)[] = [];
private rowWidgets: RowWidgets[] = [];
constructor(private readonly state: EditorState) {
super({ gap: 0 });
@@ -45,7 +100,14 @@ export class ListEditor extends VStack {
this.add(this.scroll, 1);
const footer = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.addButton = new Button('+ Item', { variant: 'ghost', onClick: () => this.addItem() });
this.addButton = new Dropdown('+ Item', { variant: 'ghost' });
// Built on click, not once: which kinds fit depends on the list the tab is showing.
this.addButton.onClick = () => {
const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS;
new Menu(
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => this.addItem(kind) })),
).openBelow(this.addButton);
};
footer.add(this.addButton);
footer.add(new Spacer(), 1);
this.add(footer);
@@ -58,21 +120,30 @@ export class ListEditor extends VStack {
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
fieldAt(index: number, field = ''): { x: number; y: number } | null {
const found = this.fields[index];
const widget = field ? found?.parts?.get(field) : found?.widget;
if (!widget) return null;
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
const row = this.rowWidgets[index];
return centreOf(field ? row?.parts.get(field) : row?.value);
}
/** For the check scripts: the kind mark of a row, or of one field of a struct item. */
markAt(index: number, field = ''): { x: number; y: number } | null {
const row = this.rowWidgets[index];
return centreOf(field ? row?.marks.get(field) : row?.mark);
}
/** For the check scripts: the name field of one field of a struct item. */
nameAt(index: number, field: string): { x: number; y: number } | null {
return centreOf(this.rowWidgets[index]?.names.get(field));
}
/** For the check scripts: what the tab shows and how its rows read right now. */
debugInfo(): { title: string; items: string[]; kind: string } {
debugInfo(): { title: string; items: string[]; kinds: ListItemKind[]; offers: ListItemKind[] } {
const access = this.access();
if (!access) return { title: '', items: [], kind: '' };
if (!access) return { title: '', items: [], kinds: [], offers: [] };
return {
title: access.title,
items: access.value.map((item) => textOf(item)),
kind: access.def.items,
kinds: access.value.map(itemKindOf),
offers: [...access.kinds],
};
}
@@ -81,11 +152,18 @@ export class ListEditor extends VStack {
return slot ? resolveList(this.state, slot) : null;
}
/** Everything that decides how many rows there are and of what kind – a struct's fields included. */
/** Everything that decides how many rows there are and of what kind – a struct item's fields included. */
private signatureOf(access: ListAccess | null): string {
if (!access) return '';
const fields = (access.def.fields ?? []).map((f) => `${f.key}:${f.kind}`).join(',');
return `${access.title}|${access.def.items}|${fields}|${access.value.length}`;
const shape = access.value
.map(
(item) =>
`${itemKindOf(item)}${structItemFields(item)
.map((f) => `:${f.key}:${f.kind}`)
.join('')}`,
)
.join(',');
return `${access.title}|${shape}|${access.value.length}`;
}
private refresh(): void {
@@ -105,7 +183,7 @@ export class ListEditor extends VStack {
this.rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
this.scroll.setContent(this.rows);
this.setters = [];
this.fields = [];
this.rowWidgets = [];
this.title.setText(access ? access.title : 'No list open');
this.count.setText(access ? `${access.value.length} items` : '');
@@ -122,32 +200,142 @@ export class ListEditor extends VStack {
}
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
const kind = itemKindOf(item);
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
const tall = access.def.items === 'struct';
const tall = kind === 'struct';
const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' });
if (!tall) row.minHeight = Theme.rowHeight;
const label = new Label(String(index), { style: 'small', align: 'right' });
label.minWidth = INDEX_WIDTH;
if (tall) label.minHeight = Theme.rowHeight;
row.add(label);
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
addValue(row, field);
const mark = markPicker(
kindMark(kind),
access.kinds
.filter((next) => next !== kind)
.map((next) => ({
label: KIND_LABELS[next],
// Through `itemValue`, so a color keeps its number when it becomes one.
onSelect: () => this.replaceItem(access, index, makeItem(next, itemValue(item))),
})),
);
if (tall) mark.minHeight = Theme.rowHeight;
row.add(mark);
if (tall) this.addStructItem(access, row, mark, item, index);
else {
const field = createField(
this.ctx,
itemDef(String(index), kind),
itemValue(item) ?? '',
access.itemHandlers(index),
);
addValue(row, field);
this.setters.push((next) => field.setValue(itemValue(next) ?? ''));
this.rowWidgets.push(rowWidgets(mark, field.widget));
}
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
this.setters.push(field.setValue);
this.fields.push({ widget: field.widget, parts: field.parts });
return row;
}
private addItem(): void {
/** A struct item carries its own shape, so its fields are named, typed and removed right here. */
private addStructItem(
access: ListAccess,
row: HStack,
mark: Widget,
item: PropertyValue,
index: number,
): void {
const bundle = structItemOf(item);
const fields = structItemFields(item);
const stack = new VStack({ gap: Theme.gap });
const widgets = rowWidgets(mark, stack);
const setters = new Map<string, (value: PropertyValue) => void>();
const handlers = access.itemHandlers(index);
for (const field of fields) {
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.minHeight = Theme.rowHeight;
const fieldMark = markPicker(
kindMark(field.kind),
FIELD_KINDS.filter((next) => next !== field.kind).map((next) => ({
label: KIND_LABELS[next],
onSelect: () =>
this.replaceItem(access, index, {
...bundle,
[field.key]: makeFlatItem(next, itemValue(bundle[field.key])),
}),
})),
);
const name = nameField(field.key, (to) => {
if (to && to !== field.key) this.replaceItem(access, index, renamedField(bundle, field.key, to));
});
line.add(fieldMark);
line.add(name);
const sub = createField(
this.ctx,
primitiveDef(field.key, field.kind),
itemValue(bundle[field.key]) ?? '',
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
);
setters.set(field.key, addValue(line, sub));
widgets.parts.set(field.key, sub.widget);
widgets.marks.set(field.key, fieldMark);
widgets.names.set(field.key, name);
line.add(removeButton(() => this.replaceItem(access, index, withoutField(bundle, field.key))));
stack.add(line);
}
const foot = new HStack({ gap: Theme.gap, align: 'center' });
foot.minHeight = Theme.rowHeight;
foot.add(addFieldMenu((kind) => this.addField(access, index, bundle, kind)));
foot.add(new Spacer(), 1);
stack.add(foot);
row.add(stack, 1);
this.setters.push((next) => {
const now = structItemOf(next);
for (const field of fields) setters.get(field.key)?.(itemValue(now[field.key]) ?? '');
});
this.rowWidgets.push(widgets);
}
private addField(access: ListAccess, index: number, bundle: PropertyValue, kind: FieldKind): void {
const fields = structItemFields(bundle);
const key = nextFieldName(fields);
this.replaceItem(access, index, { ...structItemOf(bundle), [key]: makeFlatItem(kind, undefined) });
}
/** Every shape change is a value edit: the kind lives in the item, so there is nothing else to set. */
private replaceItem(access: ListAccess, index: number, item: ListItem): void {
access.replace(access.value.map((value, i) => (i === index ? item : value)));
}
private addItem(kind: ListItemKind): void {
const access = this.access();
if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]);
if (access) access.replace([...access.value, makeItem(kind, undefined)]);
}
}
/** Item `index` as a definition, so the inspector's own widgets render a row of the list. */
function itemDef(def: ListDef, index: number): PrimitiveDef | StructDef {
const key = String(index);
return def.items === 'struct'
? { key, label: key, kind: 'struct', fields: def.fields ?? [], default: structOf(def.fields ?? [], {}) }
: primitiveDef(key, def.items);
/** The field's name, changeable: a struct item's shape is its own, not a declaration elsewhere. */
function nameField(key: string, rename: (next: string) => void): TextField {
return new TextField(key, { width: NAME_WIDTH, onChange: (next) => rename(next.trim()) });
}
function addFieldMenu(add: (kind: FieldKind) => void): Dropdown {
const button = new Dropdown('+ Field', { variant: 'ghost' });
button.onClick = () => {
new Menu(FIELD_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => add(kind) }))).openBelow(
button,
);
};
return button;
}
const withoutField = (bundle: PropertyValue, key: string): ListItem => {
const next = { ...structItemOf(bundle) };
delete next[key];
return next;
};
const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem => {
const next: Record<string, FieldValue> = {};
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
return next;
};
+31 -26
View File
@@ -10,13 +10,14 @@ 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 { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
import {
coerceFieldValue,
coerceListItem,
FIELD_KINDS,
LIST_ITEM_KINDS,
type ListItemKind,
type PropertyDef,
schemaForAsset,
structOf,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
@@ -31,6 +32,12 @@ export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
export interface ListAccess {
readonly def: ListDef;
readonly value: ListValue;
/**
* The kinds this list may hold. A list in a struct field takes primitives only – `struct → list →
* struct` is past the depth the stored value goes, so offering it would add an item that the next
* write silently drops (`flatListOf`).
*/
readonly kinds: readonly ListItemKind[];
/** What the tab header says the list belongs to. */
readonly title: string;
/** One item, previewed and committed exactly as the inspector does it. */
@@ -57,13 +64,9 @@ export function resolveList(state: EditorState, slot: ListSlot): ListAccess | nu
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
const withItem = (list: ListValue, index: number, item: ListItem): ListValue =>
list.map((value, i) => (i === index ? item : value));
/** The item at `index` in the shape the declaration promises, so a field write never invents one. */
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)
@@ -86,7 +89,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string
});
return access<Target>(
state,
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
{ key: field, label: field, kind: 'list', default: [] },
`${owner.title} · ${field}`,
() => asList(structOf(fields, owner.current())[field]),
{
@@ -96,6 +99,7 @@ function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string
remove: owner.spec.remove,
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
},
FIELD_KINDS,
);
}
@@ -110,9 +114,11 @@ function access<Target>(
title: string,
current: () => ListValue,
spec: Omit<PreviewedValue<Target>, 'toModel'>,
kinds: readonly ListItemKind[] = LIST_ITEM_KINDS,
): ListAccess {
return {
def,
kinds,
title,
value: current(),
itemHandlers: (index) => ({
@@ -120,27 +126,26 @@ function access<Target>(
{ state },
{
...spec,
toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)),
// The row keeps the kind it has: a color item written from its field stays a color.
toModel: (input) => withItem(current(), index, itemLike(current()[index], input)),
},
),
// A struct item is edited field by field, and each write rebuilds the whole item from what
// is in the list at that moment -- the same rule as a row, one level deeper.
...(def.items === 'struct'
? {
structFieldHandlers: (field: StructFieldDef) =>
previewingHandlers<Target>(
{ state },
{
...spec,
toModel: (input) =>
withItem(current(), index, {
...structItemAt(current(), index, def),
[field.key]: coerceFieldValue(input, field),
}),
},
),
}
: {}),
structFieldHandlers: (field: { key: string }) =>
previewingHandlers<Target>(
{ state },
{
...spec,
toModel: (input) => {
const item = structItemOf(current()[index]);
return withItem(current(), index, {
...item,
[field.key]: fieldLike(item[field.key], input),
});
},
},
),
}),
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
// A preview does not, which is why `toModel` above has to.