Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number field; a struct field can hold a list, so ListSlot carries an optional field name and the three owner cases only say what sits at a key; and a list variable picks its item kind in the inspector (marker doubles as the chooser), so a list of structs no longer has to be born from a project file. The declaration keeps the storable shapes finite: a struct field may be a list, but that list holds primitives only, so list->struct->primitive and struct->list->primitive are the two deepest forms. check 674, e2e 483, jump-run 24, Tetris 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe3c63c48c
commit
10d030376a
18 files changed
+984
-184
No files matched your search
@@ -11,16 +11,21 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
STRUCT_FIELD_KINDS,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -32,10 +37,11 @@ import {
|
||||
createField,
|
||||
createFieldRow,
|
||||
type FieldHandlers,
|
||||
fieldDef,
|
||||
labelledRow,
|
||||
primitiveDef,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import type { ListDef } from '../list/slot';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import {
|
||||
listOpener,
|
||||
@@ -62,20 +68,43 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
|
||||
/** The mark a list wears for what it holds – `≡` plus the item's own mark. */
|
||||
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
|
||||
number: 'Numbers',
|
||||
boolean: 'Booleans',
|
||||
text: 'Texts',
|
||||
color: 'Colors',
|
||||
struct: 'Structs',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
list: 'List',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
|
||||
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
|
||||
|
||||
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
list: '≡',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -88,8 +117,11 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
|
||||
const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+');
|
||||
// A list's item kind decides whether it has field rows at all, so it is structure too.
|
||||
const items = def.kind === 'list' ? `/${def.items}` : '';
|
||||
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -181,7 +213,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
|
||||
return {
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||
...structFields<Asset>(host, def, spec),
|
||||
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,9 +245,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
}
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def));
|
||||
row.add(
|
||||
new TextField(def.key, {
|
||||
width: 90,
|
||||
@@ -236,9 +266,84 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
);
|
||||
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
|
||||
host.body.add(row);
|
||||
if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def);
|
||||
}
|
||||
}
|
||||
|
||||
function kindMark(def: PropertyDef): Label {
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
return mark;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind mark of a list doubles as the chooser for what it holds – there is no room for a
|
||||
* column of its own, and the mark is the one place that already says it. Changing it runs every
|
||||
* value through the new declaration, so a list of numbers becomes a list of texts and back.
|
||||
*/
|
||||
function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown {
|
||||
// Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron.
|
||||
const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 });
|
||||
picker.onClick = () => {
|
||||
new Menu(
|
||||
LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({
|
||||
label: LIST_ITEM_LABELS[items],
|
||||
onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)),
|
||||
})),
|
||||
).openBelow(picker);
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`).
|
||||
* Their values are edited in the List tab, one row per item, which is why there is no value column.
|
||||
*/
|
||||
function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): void {
|
||||
for (const field of def.fields ?? []) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(new Spacer(FIELD_INDENT));
|
||||
const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(
|
||||
new TextField(field.key, {
|
||||
width: 90,
|
||||
onChange: (next) =>
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
}),
|
||||
);
|
||||
host.body.add(row);
|
||||
}
|
||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
foot.minHeight = Theme.rowHeight;
|
||||
foot.add(new Spacer(FIELD_INDENT));
|
||||
foot.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
new Menu(
|
||||
FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }),
|
||||
),
|
||||
})),
|
||||
).openBelow(add);
|
||||
};
|
||||
foot.add(add);
|
||||
host.body.add(foot);
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct variable is a block rather than a row: the head renames it and adds fields, each field
|
||||
* below carries its own name, its value and a remove. That is the port editor `Pack` already has,
|
||||
@@ -264,11 +369,11 @@ function buildStructVariable(
|
||||
head.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = FIELD_KINDS.map((kind) => ({
|
||||
const items = STRUCT_FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
|
||||
new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind)),
|
||||
),
|
||||
}));
|
||||
new Menu(items).openBelow(add);
|
||||
@@ -284,7 +389,12 @@ function buildStructVariable(
|
||||
host.body.add(head);
|
||||
|
||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key));
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
|
||||
kind: 'asset',
|
||||
assetId: asset.id,
|
||||
key: def.key,
|
||||
field,
|
||||
}));
|
||||
const fields = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of def.fields) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
@@ -300,12 +410,7 @@ function buildStructVariable(
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
const item = createField(
|
||||
host,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
perField(field),
|
||||
);
|
||||
const item = createField(host, fieldDef(field), structFieldValue(value, field), perField(field));
|
||||
row.add(item.widget, item.grow ? 1 : 0);
|
||||
if (!item.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import {
|
||||
emptyField,
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
@@ -199,9 +199,16 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
|
||||
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||||
|
||||
/** A struct field as a property definition – a list field becomes the same row a list property has. */
|
||||
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
||||
return field.kind === 'list'
|
||||
? { key: field.key, label, kind: 'list', default: [], items: field.items }
|
||||
: primitiveDef(field.key, field.kind, label);
|
||||
}
|
||||
|
||||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||||
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind);
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
|
||||
}
|
||||
|
||||
/** The bare widget for a property definition (no label, no reset). */
|
||||
@@ -250,12 +257,7 @@ export function createField(
|
||||
const fields = new Map<string, Field>();
|
||||
for (const field of def.fields) {
|
||||
const { row } = labelledRow(field.key);
|
||||
const sub = createField(
|
||||
ctx,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
per(field),
|
||||
);
|
||||
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
|
||||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||||
if (!sub.grow) row.add(new Spacer(), 1);
|
||||
fields.set(field.key, sub);
|
||||
|
||||
@@ -320,7 +320,13 @@ function propertyRow(
|
||||
{
|
||||
...previewingHandlers<ObjectRef>(host, overrideSpec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({
|
||||
kind: 'object',
|
||||
holderId,
|
||||
objectId,
|
||||
key: def.key,
|
||||
field,
|
||||
})),
|
||||
},
|
||||
{
|
||||
extra: aspectActions(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
@@ -31,23 +31,30 @@ export function structFields<Target>(
|
||||
host: { state: EditorState },
|
||||
def: PropertyDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {};
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {};
|
||||
}
|
||||
|
||||
/** `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. */
|
||||
export function structFieldsOf<Target>(
|
||||
host: { state: EditorState },
|
||||
def: StructDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): (field: StructFieldDef) => FieldHandlers {
|
||||
return (field) =>
|
||||
previewingHandlers<Target>(host, {
|
||||
return (field) => ({
|
||||
...previewingHandlers<Target>(host, {
|
||||
...spec,
|
||||
toModel: (input, target) => ({
|
||||
...structOf(def.fields, spec.read(target)),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
});
|
||||
}),
|
||||
...(field.kind === 'list' && fieldSlot
|
||||
? { openList: () => host.state.openList(fieldSlot(field.key)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an inspector row finds the value it edits, and what committing it means. */
|
||||
|
||||
+93
-39
@@ -2,23 +2,24 @@
|
||||
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
|
||||
* three cases is the spec the matching inspector section already uses, so an item edited in the
|
||||
* List tab goes through the same command, the same coercion and the same undo step as the row
|
||||
* it replaces.
|
||||
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
|
||||
* level deeper, and the writes still go through the owner's command as a whole bundle.
|
||||
*/
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findNode, type GraphNode } from '../../core/graph/Graph';
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
@@ -43,19 +44,24 @@ export interface ListAccess {
|
||||
replace(next: ListValue): void;
|
||||
}
|
||||
|
||||
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
|
||||
interface Owner<Target> {
|
||||
def: PropertyDef;
|
||||
/** Its effective value, re-read on every call – a preview writes into the model. */
|
||||
current: () => PropertyValue | undefined;
|
||||
title: string;
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>;
|
||||
}
|
||||
|
||||
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
||||
if (slot.kind === 'asset') return assetAccess(state, slot);
|
||||
if (slot.kind === 'object') return objectAccess(state, slot);
|
||||
return nodeAccess(state, slot);
|
||||
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
|
||||
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
|
||||
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
||||
return inSlot(nodeOwner(state, slot));
|
||||
}
|
||||
|
||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||
|
||||
const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
|
||||
const def = schema.find((d) => d.key === key);
|
||||
return def?.kind === 'list' ? def : null;
|
||||
};
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
|
||||
list.map((value, i) => (i === index ? item : value));
|
||||
|
||||
@@ -63,6 +69,41 @@ const withItem = (list: ListValue, index: number, item: number | string | boolea
|
||||
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
|
||||
structOf(def.fields ?? [], list[index]);
|
||||
|
||||
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
|
||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
|
||||
if (field === undefined)
|
||||
return owner.def.kind === 'list'
|
||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
|
||||
: null;
|
||||
if (owner.def.kind !== 'struct') return null;
|
||||
const declared = owner.def.fields.find((f) => f.key === field);
|
||||
if (declared?.kind !== 'list') return null;
|
||||
const fields = owner.def.fields;
|
||||
const read = (target: Target) => {
|
||||
const bundle = owner.spec.read(target);
|
||||
return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]);
|
||||
};
|
||||
// Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the
|
||||
// list alone would drop its neighbours, and a snapshot would undo an edit made in between.
|
||||
const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({
|
||||
...structOf(fields, target === undefined ? undefined : owner.spec.read(target)),
|
||||
[field]: asList(list),
|
||||
});
|
||||
return access<Target>(
|
||||
state,
|
||||
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
|
||||
`${owner.title} · ${field}`,
|
||||
() => asList(structOf(fields, owner.current())[field]),
|
||||
{
|
||||
target: owner.spec.target,
|
||||
read,
|
||||
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
|
||||
remove: owner.spec.remove,
|
||||
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
|
||||
* the next keystroke has to build on what is in there now, and the commit on what the restore
|
||||
@@ -99,7 +140,7 @@ function access<Target>(
|
||||
toModel: (input) =>
|
||||
withItem(current(), index, {
|
||||
...structItemAt(current(), index, def),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -112,67 +153,80 @@ function access<Target>(
|
||||
};
|
||||
}
|
||||
|
||||
function assetAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): ListAccess | null {
|
||||
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
|
||||
const asset = findAsset(state.project, slot.assetId);
|
||||
const def = asset && listDefIn(schemaForAsset(asset), slot.key);
|
||||
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
|
||||
if (!asset || !def) return null;
|
||||
const at = () => findAsset(state.project, slot.assetId);
|
||||
return access<Asset>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${asset.name} · ${def.label}`,
|
||||
() => {
|
||||
title: `${asset.name} · ${def.label}`,
|
||||
current: () => {
|
||||
const now = at();
|
||||
return now ? asList(resolveAssetProps(now)[slot.key]) : [];
|
||||
return now ? resolveAssetProps(now)[slot.key] : undefined;
|
||||
},
|
||||
{
|
||||
spec: {
|
||||
target: at,
|
||||
read: (found) => found.props[slot.key],
|
||||
write: (found, value) => (found.props[slot.key] = value),
|
||||
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function objectAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'object' }>): ListAccess | null {
|
||||
function objectOwner(
|
||||
state: EditorState,
|
||||
slot: Extract<ListSlot, { kind: 'object' }>,
|
||||
): Owner<ObjectRef> | null {
|
||||
const ref = currentRef(state, slot.holderId, slot.objectId);
|
||||
const owner = ref && findAsset(state.project, ref.object.assetId);
|
||||
const def = owner && listDefIn(schemaForAsset(owner), slot.key);
|
||||
const def = owner && schemaForAsset(owner).find((d) => d.key === slot.key);
|
||||
if (!ref || !def) return null;
|
||||
return access<ObjectRef>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${ref.object.name} · ${def.label}`,
|
||||
() => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]),
|
||||
{
|
||||
title: `${ref.object.name} · ${def.label}`,
|
||||
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
|
||||
spec: {
|
||||
target: () => currentRef(state, slot.holderId, slot.objectId),
|
||||
read: (found) => found.object.overrides[slot.key],
|
||||
write: (found, value) => found.setOverride(slot.key, value),
|
||||
remove: (found) => found.setOverride(slot.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): ListAccess | null {
|
||||
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
|
||||
const found = nodeIn(state, slot);
|
||||
if (!found) return null;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const def = nodeDef && listDefIn(nodeDef.params, slot.key);
|
||||
if (!def) return null;
|
||||
return access<GraphNode>(
|
||||
state,
|
||||
const def =
|
||||
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
|
||||
if (!nodeDef || !def) return null;
|
||||
return {
|
||||
def,
|
||||
`${nodeDef.label} · ${def.label}`,
|
||||
() => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default),
|
||||
{
|
||||
title: `${nodeDef.label} · ${def.label}`,
|
||||
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
|
||||
spec: {
|
||||
target: () => nodeIn(state, slot)?.node,
|
||||
read: (node) => node.params[slot.key],
|
||||
write: (node, value) => (node.params[slot.key] = value),
|
||||
remove: (node) => delete node.params[slot.key],
|
||||
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
|
||||
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
|
||||
* something the node no longer reads.
|
||||
*/
|
||||
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
|
||||
const port = inlineInputs(def).find((p) => p.key === key);
|
||||
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
|
||||
const inline = inlineInputDef(port);
|
||||
return inline.kind === 'list' ? inline : null;
|
||||
}
|
||||
|
||||
/** The node together with its graph, because `resolveDef` needs both. */
|
||||
|
||||
+6
-2
@@ -29,14 +29,18 @@ export type Selection =
|
||||
/**
|
||||
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
|
||||
* selects, it is a value inside something already selected, and exactly three places hold one.
|
||||
* `field` names a list held by a struct field one level down; a struct item of a list needs no
|
||||
* such step, because the List tab renders it inline.
|
||||
*/
|
||||
export type ListSlot =
|
||||
export type ListSlot = { field?: string } & (
|
||||
| { kind: 'asset'; assetId: string; key: string }
|
||||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string };
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||||
);
|
||||
|
||||
export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
if (a.field !== b.field) return false;
|
||||
switch (a.kind) {
|
||||
case 'asset':
|
||||
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
||||
|
||||
Reference in new issue
Block a user