Finish the struct and list leftovers: inline lists, list fields, item kinds
An unconnected list input is a button into the List tab instead of a number field; a struct field can hold a list, so ListSlot carries an optional field name and the three owner cases only say what sits at a key; and a list variable picks its item kind in the inspector (marker doubles as the chooser), so a list of structs no longer has to be born from a project file. The declaration keeps the storable shapes finite: a struct field may be a list, but that list holds primitives only, so list->struct->primitive and struct->list->primitive are the two deepest forms. check 674, e2e 483, jump-run 24, Tetris 19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe3c63c48c
commit
10d030376a
18 files changed
+984
-184
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
type ListValue,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
@@ -8,10 +10,13 @@ import {
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import {
|
||||
emptyField,
|
||||
coerceValue,
|
||||
emptyFieldValue,
|
||||
isCommonProp,
|
||||
type ListItemKind,
|
||||
type PrimitiveFieldDef,
|
||||
primitiveFieldsOf,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
variableDef,
|
||||
@@ -151,16 +156,36 @@ export function isValidFieldName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
function requireStruct(project: Project, assetId: string, key: string): StructDef {
|
||||
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
|
||||
interface FieldOwner {
|
||||
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
|
||||
readonly inList: boolean;
|
||||
fields(): readonly StructFieldDef[];
|
||||
setFields(next: readonly StructFieldDef[]): void;
|
||||
}
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
if (!def || def.kind !== 'struct') throw new CommandError(`Variable "${key}" is not a struct`);
|
||||
return def;
|
||||
if (def?.kind === 'struct')
|
||||
return {
|
||||
inList: false,
|
||||
fields: () => def.fields,
|
||||
setFields: (next) => (def.fields = next),
|
||||
};
|
||||
if (def?.kind === 'list' && def.items === 'struct')
|
||||
return {
|
||||
inList: true,
|
||||
fields: () => def.fields ?? [],
|
||||
setFields: (next) => (def.fields = primitiveFieldsOf(next)),
|
||||
};
|
||||
throw new CommandError(`Variable "${key}" has no fields`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value of one struct variable: the declared default, the behavior's own, and each instance
|
||||
* override. A field is added to or taken from all of them at once, so no value keeps a shape its
|
||||
* declaration no longer promises. The `id` is what pairs a site up again when the change is undone.
|
||||
* Every value of one variable that carries a struct: the declared default, the behavior's own, and
|
||||
* each instance override – and for a list of structs, every item of each of those. A field is added
|
||||
* to or taken from all of them at once, so no value keeps a shape its declaration no longer
|
||||
* promises. The `id` is what pairs a site up again when the change is undone.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
@@ -169,35 +194,68 @@ interface StructSite {
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const holders = valueHolders(project, assetId, key).filter(
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
|
||||
);
|
||||
const sites: StructSite[] = [];
|
||||
for (const holder of holders) {
|
||||
if (def?.kind !== 'list') {
|
||||
sites.push({
|
||||
id: holder.id,
|
||||
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
||||
write: (value) => holder.write(value),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const list = holder.read();
|
||||
if (!isList(list)) continue;
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
sites.push({
|
||||
id: `${holder.id}#${index}`,
|
||||
read: () => {
|
||||
const item = (holder.read() as ListValue)[index];
|
||||
return isStruct(item) ? item : {};
|
||||
},
|
||||
write: (value) =>
|
||||
holder.write((holder.read() as ListValue).map((item, at) => (at === index ? value : item))),
|
||||
});
|
||||
}
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
|
||||
interface ValueHolder {
|
||||
id: string;
|
||||
/** An override may simply not be there; the declared default and the behavior's own always are. */
|
||||
optional: boolean;
|
||||
read(): PropertyValue | undefined;
|
||||
write(value: PropertyValue): void;
|
||||
}
|
||||
|
||||
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
|
||||
const asset = requireBehavior(project, assetId);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
const sites: StructSite[] = [];
|
||||
if (def?.kind === 'struct')
|
||||
sites.push({
|
||||
const holders: ValueHolder[] = [];
|
||||
if (def)
|
||||
holders.push({
|
||||
id: 'declared',
|
||||
optional: false,
|
||||
read: () => def.default,
|
||||
write: (value) => {
|
||||
def.default = value;
|
||||
},
|
||||
});
|
||||
sites.push(bagSite('asset', asset.props, key));
|
||||
holders.push(bagHolder('asset', false, asset.props, key));
|
||||
for (const site of overrideSites(project, (id) => id === assetId))
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
if (isStruct(site.bag[key])) sites.push(bagSite(`${site.object.id}/${site.path}`, site.bag, key));
|
||||
return sites;
|
||||
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
|
||||
return holders;
|
||||
}
|
||||
|
||||
function bagSite(id: string, bag: PropertyBag, key: string): StructSite {
|
||||
return {
|
||||
id,
|
||||
read: () => {
|
||||
const value = bag[key];
|
||||
return isStruct(value) ? value : {};
|
||||
},
|
||||
write: (value) => {
|
||||
bag[key] = value;
|
||||
},
|
||||
};
|
||||
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
|
||||
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
||||
}
|
||||
|
||||
function withoutField(value: StructValue, key: string): StructValue {
|
||||
@@ -225,18 +283,20 @@ export class AddStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
||||
if (def.fields.some((f) => f.key === this.field.key))
|
||||
if (owner.fields().some((f) => f.key === this.field.key))
|
||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||
def.fields = [...def.fields, { ...this.field }];
|
||||
if (owner.inList && this.field.kind === 'list')
|
||||
throw new CommandError('A list field cannot sit in the items of a list');
|
||||
owner.setFields([...owner.fields(), { ...this.field }]);
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
|
||||
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
def.fields = def.fields.filter((f) => f.key !== this.field.key);
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write(withoutField(site.read(), this.field.key));
|
||||
}
|
||||
@@ -256,8 +316,8 @@ export class RemoveStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.fields];
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
const fields = [...owner.fields()];
|
||||
const field = removeFirst(fields, (f) => f.key === this.field);
|
||||
if (!field) throw new CommandError(`Field "${this.field}" not found`);
|
||||
const values = new Map<string, FieldValue>();
|
||||
@@ -267,16 +327,16 @@ export class RemoveStructFieldCommand implements Command {
|
||||
if (item !== undefined) values.set(site.id, item);
|
||||
site.write(withoutField(value, this.field));
|
||||
}
|
||||
def.fields = fields;
|
||||
owner.setFields(fields);
|
||||
this.removed = { field, values };
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.fields];
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
const fields = [...owner.fields()];
|
||||
restoreAt(fields, [this.removed.field]);
|
||||
def.fields = fields;
|
||||
owner.setFields(fields);
|
||||
for (const site of structSites(project, this.assetId, this.key)) {
|
||||
const value = this.removed.values.get(site.id);
|
||||
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
||||
@@ -305,13 +365,82 @@ export class RenameStructFieldCommand implements Command {
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const owner = requireFieldOwner(project, this.assetId, this.key);
|
||||
if (from === to) return;
|
||||
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
||||
if (!def.fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
||||
if (def.fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||||
def.fields = def.fields.map((f) => (f.key === from ? { ...f, key: to } : f));
|
||||
const fields = owner.fields();
|
||||
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
||||
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
||||
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes what a list variable holds. The declaration is the only place that says it, so every
|
||||
* value has to follow at once – each is run through `coerceValue` against the new declaration,
|
||||
* which is the same conversion an item edited in the List tab goes through.
|
||||
*/
|
||||
export class SetListItemsCommand implements Command {
|
||||
readonly label: string;
|
||||
private before?: {
|
||||
items: ListItemKind;
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
values: Map<string, PropertyValue>;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly items: ListItemKind,
|
||||
) {
|
||||
this.label = `Set ${key} items`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
if (def.items === this.items)
|
||||
throw new CommandError(`Variable "${this.key}" already holds ${this.items}`);
|
||||
const values = new Map<string, PropertyValue>();
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) values.set(holder.id, value);
|
||||
}
|
||||
this.before = { items: def.items, fields: def.fields, values };
|
||||
def.items = this.items;
|
||||
// Fields belong to struct items alone: kept while leaving so an undo has them back.
|
||||
if (this.items === 'struct' && !def.fields) def.fields = [];
|
||||
this.recoerce(project);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.before) return;
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
def.items = this.before.items;
|
||||
if (this.before.fields) def.fields = this.before.fields;
|
||||
else delete def.fields;
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = this.before.values.get(holder.id);
|
||||
if (value !== undefined) holder.write(value);
|
||||
}
|
||||
}
|
||||
|
||||
private recoerce(project: Project): void {
|
||||
const def = requireListDef(project, this.assetId, this.key);
|
||||
for (const holder of valueHolders(project, this.assetId, this.key)) {
|
||||
const value = holder.read();
|
||||
if (value !== undefined) holder.write(coerceValue(def, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireListDef(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
key: string,
|
||||
): Extract<PropertyDef, { kind: 'list' }> {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
|
||||
return def;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { Graph, GraphNode, PortType } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { asBoolean, asNumber, asString, type Value } from './values';
|
||||
import { asBoolean, asList, asNumber, asString, type Value } from './values';
|
||||
|
||||
export interface PortDef {
|
||||
key: string;
|
||||
@@ -135,6 +135,16 @@ export function inlineInputDef(port: PortDef): PropertyDef {
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||||
// No port declares an item kind, so an inline list reads as numbers – the same default
|
||||
// `+ Variable ▸ List` picks. The graph types a list as a list either way.
|
||||
case 'list':
|
||||
return {
|
||||
key: port.key,
|
||||
label: port.label,
|
||||
kind: 'list',
|
||||
default: asList(port.default),
|
||||
items: 'number',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
key: port.key,
|
||||
|
||||
@@ -56,7 +56,7 @@ export type PropertyDef =
|
||||
default: ListValue;
|
||||
items: ListItemKind;
|
||||
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
|
||||
fields?: readonly StructFieldDef[];
|
||||
fields?: readonly PrimitiveFieldDef[];
|
||||
}
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
@@ -64,19 +64,37 @@ export type PropertyDef =
|
||||
*/
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
/** What a struct field holds. No `struct`: that is the rule that bounds the nesting depth. */
|
||||
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** What a list holds – a field's kinds plus `struct`, so a list of bundles is one declaration. */
|
||||
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
export interface StructFieldDef {
|
||||
/** What a field of a struct property holds – a primitive plus `list`. */
|
||||
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
|
||||
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
|
||||
|
||||
export interface PrimitiveFieldDef {
|
||||
key: string;
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list held by a struct field. Its items are primitives, and that asymmetry is the whole point:
|
||||
* a list may hold structs and a struct may hold lists, so only forbidding one of the two here
|
||||
* keeps the storable shapes finite. `list → struct → primitive` and `struct → list → primitive`
|
||||
* are the two deepest, which is exactly what `asStoredValue` lets through.
|
||||
*/
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
items: FieldKind;
|
||||
}
|
||||
|
||||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
|
||||
|
||||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
@@ -214,7 +232,7 @@ export function variableDef(
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'list': {
|
||||
const item = items ?? 'number';
|
||||
const shape = item === 'struct' ? (fields ?? []) : undefined;
|
||||
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
|
||||
const raw = isList(defaultValue) ? defaultValue : [];
|
||||
return {
|
||||
key,
|
||||
@@ -232,6 +250,10 @@ export function variableDef(
|
||||
}
|
||||
}
|
||||
|
||||
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
|
||||
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
|
||||
}
|
||||
|
||||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||||
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
number: 0,
|
||||
@@ -244,6 +266,10 @@ export function emptyField(kind: FieldKind): number | string | boolean {
|
||||
return EMPTY_FIELD[kind];
|
||||
}
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct over exactly the declared fields: a declared field missing from `from` starts empty
|
||||
* rather than absent, so a struct value always has the shape its declaration promises.
|
||||
@@ -253,7 +279,7 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
|
||||
const value: Record<string, FieldValue> = {};
|
||||
for (const field of fields)
|
||||
value[field.key] =
|
||||
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
|
||||
field.key in source ? coerceFieldValue(source[field.key]!, field) : emptyFieldValue(field);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -298,6 +324,12 @@ export function coerceListItem(
|
||||
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
|
||||
}
|
||||
|
||||
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
|
||||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||||
if (field.kind !== 'list') return coerceField(value, field.kind);
|
||||
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
|
||||
}
|
||||
|
||||
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
|
||||
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
|
||||
if (items === 'text') return textOf(value);
|
||||
|
||||
@@ -194,13 +194,21 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
const f = asRecord(item);
|
||||
const key = typeof f.key === 'string' ? f.key : '';
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
||||
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
|
||||
const field = readField(key, f);
|
||||
if (!field) continue;
|
||||
seen.add(key);
|
||||
fields.push({ key, kind: f.kind as FieldKind });
|
||||
fields.push(field);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||||
if (f.kind === 'list')
|
||||
return { key, kind: 'list', items: isFieldKind(f.items) ? (f.items as FieldKind) : 'number' };
|
||||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -11,16 +11,21 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
SetListItemsCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
STRUCT_FIELD_KINDS,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -32,10 +37,11 @@ import {
|
||||
createField,
|
||||
createFieldRow,
|
||||
type FieldHandlers,
|
||||
fieldDef,
|
||||
labelledRow,
|
||||
primitiveDef,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import type { ListDef } from '../list/slot';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import {
|
||||
listOpener,
|
||||
@@ -62,20 +68,43 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
|
||||
/** The mark a list wears for what it holds – `≡` plus the item's own mark. */
|
||||
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
|
||||
number: 'Numbers',
|
||||
boolean: 'Booleans',
|
||||
text: 'Texts',
|
||||
color: 'Colors',
|
||||
struct: 'Structs',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
list: 'List',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
|
||||
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
|
||||
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
|
||||
|
||||
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
list: '≡',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -88,8 +117,11 @@ export function assetSignature(project: Project, assetId: string): string {
|
||||
}
|
||||
|
||||
export function defSignature(def: PropertyDef): string {
|
||||
const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : '';
|
||||
return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`;
|
||||
const declared = def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
|
||||
const fields = declared.map((f) => `${f.key}:${f.kind}`).join('+');
|
||||
// A list's item kind decides whether it has field rows at all, so it is structure too.
|
||||
const items = def.kind === 'list' ? `/${def.items}` : '';
|
||||
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -181,7 +213,7 @@ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef):
|
||||
return {
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||
...structFields<Asset>(host, def, spec),
|
||||
...structFields<Asset>(host, def, spec, (field) => ({ kind: 'asset', assetId, key: def.key, field })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,9 +245,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
}
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(def.kind === 'list' ? itemKindPicker(host, asset, def) : kindMark(def));
|
||||
row.add(
|
||||
new TextField(def.key, {
|
||||
width: 90,
|
||||
@@ -236,9 +266,84 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
);
|
||||
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
|
||||
host.body.add(row);
|
||||
if (def.kind === 'list' && def.items === 'struct') buildItemFields(host, asset, def);
|
||||
}
|
||||
}
|
||||
|
||||
function kindMark(def: PropertyDef): Label {
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
return mark;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind mark of a list doubles as the chooser for what it holds – there is no room for a
|
||||
* column of its own, and the mark is the one place that already says it. Changing it runs every
|
||||
* value through the new declaration, so a list of numbers becomes a list of texts and back.
|
||||
*/
|
||||
function itemKindPicker(host: InspectorHost, asset: BehaviorAsset, def: ListDef): Dropdown {
|
||||
// Wide enough that the widest mark (`≡Aa`) is not truncated away behind the chevron.
|
||||
const picker = new Dropdown(`≡${LIST_ITEM_MARKS[def.items]}`, { variant: 'ghost', width: 68 });
|
||||
picker.onClick = () => {
|
||||
new Menu(
|
||||
LIST_ITEM_KINDS.filter((items) => items !== def.items).map((items) => ({
|
||||
label: LIST_ITEM_LABELS[items],
|
||||
onSelect: () => host.state.execute(new SetListItemsCommand(asset.id, def.key, items)),
|
||||
})),
|
||||
).openBelow(picker);
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of the items of a list of structs: names and kinds only (primitives, see `ListFieldDef`).
|
||||
* Their values are edited in the List tab, one row per item, which is why there is no value column.
|
||||
*/
|
||||
function buildItemFields(host: InspectorHost, asset: BehaviorAsset, def: ListDef): void {
|
||||
for (const field of def.fields ?? []) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
row.add(new Spacer(FIELD_INDENT));
|
||||
const mark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(
|
||||
new TextField(field.key, {
|
||||
width: 90,
|
||||
onChange: (next) =>
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
|
||||
}),
|
||||
);
|
||||
host.body.add(row);
|
||||
}
|
||||
const foot = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
foot.minHeight = Theme.rowHeight;
|
||||
foot.add(new Spacer(FIELD_INDENT));
|
||||
foot.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
new Menu(
|
||||
FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields ?? []), kind }),
|
||||
),
|
||||
})),
|
||||
).openBelow(add);
|
||||
};
|
||||
foot.add(add);
|
||||
host.body.add(foot);
|
||||
}
|
||||
|
||||
/**
|
||||
* A struct variable is a block rather than a row: the head renames it and adds fields, each field
|
||||
* below carries its own name, its value and a remove. That is the port editor `Pack` already has,
|
||||
@@ -264,11 +369,11 @@ function buildStructVariable(
|
||||
head.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = FIELD_KINDS.map((kind) => ({
|
||||
const items = STRUCT_FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
|
||||
new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(def.fields), kind)),
|
||||
),
|
||||
}));
|
||||
new Menu(items).openBelow(add);
|
||||
@@ -284,7 +389,12 @@ function buildStructVariable(
|
||||
host.body.add(head);
|
||||
|
||||
if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small');
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key));
|
||||
const perField = structFieldsOf<Asset>(host, def, assetSpec(host, asset.id, def.key), (field) => ({
|
||||
kind: 'asset',
|
||||
assetId: asset.id,
|
||||
key: def.key,
|
||||
field,
|
||||
}));
|
||||
const fields = new Map<string, (item: PropertyValue) => void>();
|
||||
for (const field of def.fields) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
@@ -300,12 +410,7 @@ function buildStructVariable(
|
||||
host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
const item = createField(
|
||||
host,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
perField(field),
|
||||
);
|
||||
const item = createField(host, fieldDef(field), structFieldValue(value, field), perField(field));
|
||||
row.add(item.widget, item.grow ? 1 : 0);
|
||||
if (!item.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import {
|
||||
emptyField,
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
@@ -199,9 +199,16 @@ export function primitiveDef(key: string, kind: FieldKind, label = key): Primiti
|
||||
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||||
|
||||
/** A struct field as a property definition – a list field becomes the same row a list property has. */
|
||||
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
|
||||
return field.kind === 'list'
|
||||
? { key: field.key, label, kind: 'list', default: [], items: field.items }
|
||||
: primitiveDef(field.key, field.kind, label);
|
||||
}
|
||||
|
||||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||||
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind);
|
||||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
|
||||
}
|
||||
|
||||
/** The bare widget for a property definition (no label, no reset). */
|
||||
@@ -250,12 +257,7 @@ export function createField(
|
||||
const fields = new Map<string, Field>();
|
||||
for (const field of def.fields) {
|
||||
const { row } = labelledRow(field.key);
|
||||
const sub = createField(
|
||||
ctx,
|
||||
primitiveDef(field.key, field.kind),
|
||||
structFieldValue(value, field),
|
||||
per(field),
|
||||
);
|
||||
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
|
||||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||||
if (!sub.grow) row.add(new Spacer(), 1);
|
||||
fields.set(field.key, sub);
|
||||
|
||||
@@ -320,7 +320,13 @@ function propertyRow(
|
||||
{
|
||||
...previewingHandlers<ObjectRef>(host, overrideSpec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec, (field) => ({
|
||||
kind: 'object',
|
||||
holderId,
|
||||
objectId,
|
||||
key: def.key,
|
||||
field,
|
||||
})),
|
||||
},
|
||||
{
|
||||
extra: aspectActions(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
@@ -31,23 +31,30 @@ export function structFields<Target>(
|
||||
host: { state: EditorState },
|
||||
def: PropertyDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {};
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec, fieldSlot) } : {};
|
||||
}
|
||||
|
||||
/** `fieldSlot` is what a `list` field needs: its own address, so the List tab can be opened on it. */
|
||||
export function structFieldsOf<Target>(
|
||||
host: { state: EditorState },
|
||||
def: StructDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
fieldSlot?: (field: string) => ListSlot,
|
||||
): (field: StructFieldDef) => FieldHandlers {
|
||||
return (field) =>
|
||||
previewingHandlers<Target>(host, {
|
||||
return (field) => ({
|
||||
...previewingHandlers<Target>(host, {
|
||||
...spec,
|
||||
toModel: (input, target) => ({
|
||||
...structOf(def.fields, spec.read(target)),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
});
|
||||
}),
|
||||
...(field.kind === 'list' && fieldSlot
|
||||
? { openList: () => host.state.openList(fieldSlot(field.key)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an inspector row finds the value it edits, and what committing it means. */
|
||||
|
||||
+93
-39
@@ -2,23 +2,24 @@
|
||||
* Resolves a `ListSlot` to the list in it and to the two ways of writing it back. Each of the
|
||||
* three cases is the spec the matching inspector section already uses, so an item edited in the
|
||||
* List tab goes through the same command, the same coercion and the same undo step as the row
|
||||
* it replaces.
|
||||
* it replaces. A slot with a `field` names a list *inside* a struct: the same three owners, one
|
||||
* level deeper, and the writes still go through the owner's command as a whole bundle.
|
||||
*/
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { findNode, type GraphNode } from '../../core/graph/Graph';
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
|
||||
import { resolveDef } from '../../core/graph/nested';
|
||||
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { ObjectRef } from '../../core/model/instance';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
coerceFieldValue,
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
@@ -43,19 +44,24 @@ export interface ListAccess {
|
||||
replace(next: ListValue): void;
|
||||
}
|
||||
|
||||
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
|
||||
interface Owner<Target> {
|
||||
def: PropertyDef;
|
||||
/** Its effective value, re-read on every call – a preview writes into the model. */
|
||||
current: () => PropertyValue | undefined;
|
||||
title: string;
|
||||
spec: Omit<PreviewedValue<Target>, 'toModel'>;
|
||||
}
|
||||
|
||||
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
|
||||
if (slot.kind === 'asset') return assetAccess(state, slot);
|
||||
if (slot.kind === 'object') return objectAccess(state, slot);
|
||||
return nodeAccess(state, slot);
|
||||
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
|
||||
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
|
||||
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
|
||||
return inSlot(nodeOwner(state, slot));
|
||||
}
|
||||
|
||||
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
|
||||
|
||||
const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
|
||||
const def = schema.find((d) => d.key === key);
|
||||
return def?.kind === 'list' ? def : null;
|
||||
};
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
|
||||
list.map((value, i) => (i === index ? item : value));
|
||||
|
||||
@@ -63,6 +69,41 @@ const withItem = (list: ListValue, index: number, item: number | string | boolea
|
||||
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
|
||||
structOf(def.fields ?? [], list[index]);
|
||||
|
||||
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
|
||||
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
|
||||
if (field === undefined)
|
||||
return owner.def.kind === 'list'
|
||||
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
|
||||
: null;
|
||||
if (owner.def.kind !== 'struct') return null;
|
||||
const declared = owner.def.fields.find((f) => f.key === field);
|
||||
if (declared?.kind !== 'list') return null;
|
||||
const fields = owner.def.fields;
|
||||
const read = (target: Target) => {
|
||||
const bundle = owner.spec.read(target);
|
||||
return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]);
|
||||
};
|
||||
// Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the
|
||||
// list alone would drop its neighbours, and a snapshot would undo an edit made in between.
|
||||
const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({
|
||||
...structOf(fields, target === undefined ? undefined : owner.spec.read(target)),
|
||||
[field]: asList(list),
|
||||
});
|
||||
return access<Target>(
|
||||
state,
|
||||
{ key: field, label: field, kind: 'list', default: [], items: declared.items },
|
||||
`${owner.title} · ${field}`,
|
||||
() => asList(structOf(fields, owner.current())[field]),
|
||||
{
|
||||
target: owner.spec.target,
|
||||
read,
|
||||
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
|
||||
remove: owner.spec.remove,
|
||||
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
|
||||
* the next keystroke has to build on what is in there now, and the commit on what the restore
|
||||
@@ -99,7 +140,7 @@ function access<Target>(
|
||||
toModel: (input) =>
|
||||
withItem(current(), index, {
|
||||
...structItemAt(current(), index, def),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
[field.key]: coerceFieldValue(input, field),
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -112,67 +153,80 @@ function access<Target>(
|
||||
};
|
||||
}
|
||||
|
||||
function assetAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): ListAccess | null {
|
||||
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
|
||||
const asset = findAsset(state.project, slot.assetId);
|
||||
const def = asset && listDefIn(schemaForAsset(asset), slot.key);
|
||||
const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key);
|
||||
if (!asset || !def) return null;
|
||||
const at = () => findAsset(state.project, slot.assetId);
|
||||
return access<Asset>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${asset.name} · ${def.label}`,
|
||||
() => {
|
||||
title: `${asset.name} · ${def.label}`,
|
||||
current: () => {
|
||||
const now = at();
|
||||
return now ? asList(resolveAssetProps(now)[slot.key]) : [];
|
||||
return now ? resolveAssetProps(now)[slot.key] : undefined;
|
||||
},
|
||||
{
|
||||
spec: {
|
||||
target: at,
|
||||
read: (found) => found.props[slot.key],
|
||||
write: (found, value) => (found.props[slot.key] = value),
|
||||
commit: (value) => new SetAssetPropertyCommand(slot.assetId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function objectAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'object' }>): ListAccess | null {
|
||||
function objectOwner(
|
||||
state: EditorState,
|
||||
slot: Extract<ListSlot, { kind: 'object' }>,
|
||||
): Owner<ObjectRef> | null {
|
||||
const ref = currentRef(state, slot.holderId, slot.objectId);
|
||||
const owner = ref && findAsset(state.project, ref.object.assetId);
|
||||
const def = owner && listDefIn(schemaForAsset(owner), slot.key);
|
||||
const def = owner && schemaForAsset(owner).find((d) => d.key === slot.key);
|
||||
if (!ref || !def) return null;
|
||||
return access<ObjectRef>(
|
||||
state,
|
||||
return {
|
||||
def,
|
||||
`${ref.object.name} · ${def.label}`,
|
||||
() => asList(currentProps(state, slot.holderId, slot.objectId)?.[slot.key]),
|
||||
{
|
||||
title: `${ref.object.name} · ${def.label}`,
|
||||
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
|
||||
spec: {
|
||||
target: () => currentRef(state, slot.holderId, slot.objectId),
|
||||
read: (found) => found.object.overrides[slot.key],
|
||||
write: (found, value) => found.setOverride(slot.key, value),
|
||||
remove: (found) => found.setOverride(slot.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(slot.holderId, slot.objectId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAccess(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): ListAccess | null {
|
||||
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
|
||||
const found = nodeIn(state, slot);
|
||||
if (!found) return null;
|
||||
const nodeDef = resolveDef(found.node, found.graph, behaviorLookup(state.project));
|
||||
const def = nodeDef && listDefIn(nodeDef.params, slot.key);
|
||||
if (!def) return null;
|
||||
return access<GraphNode>(
|
||||
state,
|
||||
const def =
|
||||
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
|
||||
if (!nodeDef || !def) return null;
|
||||
return {
|
||||
def,
|
||||
`${nodeDef.label} · ${def.label}`,
|
||||
() => asList(nodeIn(state, slot)?.node.params[slot.key] ?? def.default),
|
||||
{
|
||||
title: `${nodeDef.label} · ${def.label}`,
|
||||
current: () => nodeIn(state, slot)?.node.params[slot.key] ?? def.default,
|
||||
spec: {
|
||||
target: () => nodeIn(state, slot)?.node,
|
||||
read: (node) => node.params[slot.key],
|
||||
write: (node, value) => (node.params[slot.key] = value),
|
||||
remove: (node) => delete node.params[slot.key],
|
||||
commit: (value) => new SetNodeParamCommand(slot.assetId, slot.nodeId, slot.key, value),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
|
||||
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
|
||||
* something the node no longer reads.
|
||||
*/
|
||||
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
|
||||
const port = inlineInputs(def).find((p) => p.key === key);
|
||||
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
|
||||
const inline = inlineInputDef(port);
|
||||
return inline.kind === 'list' ? inline : null;
|
||||
}
|
||||
|
||||
/** The node together with its graph, because `resolveDef` needs both. */
|
||||
|
||||
+6
-2
@@ -29,14 +29,18 @@ export type Selection =
|
||||
/**
|
||||
* Which list the List tab edits. Deliberately not a `Selection`: a list is not something one
|
||||
* selects, it is a value inside something already selected, and exactly three places hold one.
|
||||
* `field` names a list held by a struct field one level down; a struct item of a list needs no
|
||||
* such step, because the List tab renders it inline.
|
||||
*/
|
||||
export type ListSlot =
|
||||
export type ListSlot = { field?: string } & (
|
||||
| { kind: 'asset'; assetId: string; key: string }
|
||||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string };
|
||||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||||
);
|
||||
|
||||
export function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
if (a.field !== b.field) return false;
|
||||
switch (a.kind) {
|
||||
case 'asset':
|
||||
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
||||
|
||||
Reference in new issue
Block a user