Let a variable declare struct fields
The value type was already there: StructValue, textOf, sameValue, writeTyped/mergeStruct and the struct port type came with the first part, and Get/Set variable carry any-ports, so a bundle already flowed through the graph unchanged. What was missing was the declaration. PropertyDef gains kind 'struct' with a list of fields, VARIABLE_KINDS a fifth kind, variableDef a fifth parameter. A declared field is a primitive - a list field would need a nested editor and therefore the path in the slot address, the same step "a list of structs" is waiting for. coerceValue now returns PropertyValue rather than FieldValue, the first value type that is not a field; none of its five callers break. In the inspector a struct variable is a block, not a tab. The list tab exists because hundreds of rows fit in no inspector row; a struct is the opposite case - few fields that fit under each other. It is the port editor Pack already has, one level down and with a value column: type, key and default value in one place. A placed object sees the same fields as plain value rows: overriding yes, declaring no. Adding or removing a field touches the declaration, the default, the behavior's value and every instance override in one command, so a stored value can never keep a shape its declaration no longer promises. The row handlers read the bundle at the moment of the change rather than closing over a snapshot - a field writes the whole bundle, so with a snapshot the second edited row revives the first one's old value. defSignature carries the fields, otherwise a new row appears only at the next rebuild. Fixes a fixture fault this uncovered: step 10 pushed its text object into the scene without instanceEdits, which overrideSites walks with Object.entries. AddStructFieldCommand is the first caller to reach it from that state; the step was green alone and failed three checks in the full suite. Moves the dynamic-ports note to done - all three of its parts are built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d07946c74
commit
0449218cb6
17 files changed
+1146
-69
No files matched your search
@@ -18,3 +18,8 @@ export function nextAssetName(project: Project, base: string): string {
|
||||
export function nextVariableName(asset: BehaviorAsset, base = 'var'): string {
|
||||
return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1);
|
||||
}
|
||||
|
||||
/** "field1", "field2", … – free within the one struct, which is the only place the name has to be unique. */
|
||||
export function nextFieldName(fields: readonly { key: string }[], base = 'field'): string {
|
||||
return firstFree(new Set(fields.map((f) => f.key)), (i) => `${base}${i}`, 1);
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import {
|
||||
type FieldValue,
|
||||
isStruct,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
emptyField,
|
||||
isCommonProp,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
@@ -123,8 +137,179 @@ export class RenameVariableCommand implements Command {
|
||||
}
|
||||
|
||||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||||
|
||||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name);
|
||||
return isValidFieldName(name) && !isCommonProp(name);
|
||||
}
|
||||
|
||||
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
read(): StructValue;
|
||||
write(value: StructValue): void;
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const asset = requireBehavior(project, assetId);
|
||||
const def = asset.variables.find((v) => v.key === key);
|
||||
const sites: StructSite[] = [];
|
||||
if (def?.kind === 'struct')
|
||||
sites.push({
|
||||
id: 'declared',
|
||||
read: () => def.default,
|
||||
write: (value) => {
|
||||
def.default = value;
|
||||
},
|
||||
});
|
||||
sites.push(bagSite('asset', 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;
|
||||
}
|
||||
|
||||
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 withoutField(value: StructValue, key: string): StructValue {
|
||||
const next: Record<string, FieldValue> = { ...value };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */
|
||||
function renameField(value: StructValue, from: string, to: string): StructValue {
|
||||
const next: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
export class AddStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly field: StructFieldDef,
|
||||
) {
|
||||
this.label = `Add field ${field.key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(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))
|
||||
throw new CommandError(`Field "${this.field.key}" already exists`);
|
||||
def.fields = [...def.fields, { ...this.field }];
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write({ ...site.read(), [this.field.key]: emptyField(this.field.kind) });
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
def.fields = def.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));
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */
|
||||
export class RemoveStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly field: string,
|
||||
) {
|
||||
this.label = `Remove field ${field}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const def = requireStruct(project, this.assetId, this.key);
|
||||
const fields = [...def.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>();
|
||||
for (const site of structSites(project, this.assetId, this.key)) {
|
||||
const value = site.read();
|
||||
const item = value[this.field];
|
||||
if (item !== undefined) values.set(site.id, item);
|
||||
site.write(withoutField(value, this.field));
|
||||
}
|
||||
def.fields = 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];
|
||||
restoreAt(fields, [this.removed.field]);
|
||||
def.fields = 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameStructFieldCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly key: string,
|
||||
private readonly from: string,
|
||||
private readonly to: string,
|
||||
) {
|
||||
this.label = `Rename field ${from}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const def = requireStruct(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));
|
||||
for (const site of structSites(project, this.assetId, this.key))
|
||||
site.write(renameField(site.read(), from, to));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
type ListValue,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
} from '../model/Properties';
|
||||
|
||||
@@ -47,14 +49,27 @@ export type PropertyDef =
|
||||
* list, so one `list` port fits them all. There is no `object` item kind: a list is stored in the
|
||||
* project file, and an object is a live handle that no reload survives.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind };
|
||||
| { key: string; label: string; kind: 'list'; default: ListValue; items: ListItemKind }
|
||||
/**
|
||||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||||
* keep its shape while single fields are written (`mergeStruct`).
|
||||
*/
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
export const LIST_ITEM_KINDS = ['number', 'text', 'boolean', 'color'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
/** A field holds what a list item holds: a field that were itself a struct is what `FieldValue` rules out. */
|
||||
export interface StructFieldDef {
|
||||
key: string;
|
||||
kind: ListItemKind;
|
||||
}
|
||||
|
||||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list'] as const;
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const;
|
||||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||
|
||||
/**
|
||||
@@ -176,6 +191,7 @@ export function variableDef(
|
||||
kind: VariableKind,
|
||||
defaultValue?: PropertyValue,
|
||||
items?: ListItemKind,
|
||||
fields?: readonly StructFieldDef[],
|
||||
): PropertyDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
@@ -192,11 +208,40 @@ export function variableDef(
|
||||
default: isList(defaultValue) ? defaultValue : [],
|
||||
items: items ?? 'number',
|
||||
};
|
||||
case 'struct': {
|
||||
const declared = fields ?? [];
|
||||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||||
const EMPTY_FIELD: Record<ListItemKind, number | string | boolean> = {
|
||||
number: 0,
|
||||
text: '',
|
||||
boolean: false,
|
||||
color: 0,
|
||||
};
|
||||
|
||||
export function emptyField(kind: ListItemKind): number | string | boolean {
|
||||
return EMPTY_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.
|
||||
*/
|
||||
export function structOf(fields: readonly StructFieldDef[], from: PropertyValue | undefined): StructValue {
|
||||
const source = isStruct(from) ? from : {};
|
||||
const value: Record<string, FieldValue> = {};
|
||||
for (const field of fields)
|
||||
value[field.key] =
|
||||
field.key in source ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Clamps / coerces a raw value so it satisfies the definition. */
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue {
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||
switch (def.kind) {
|
||||
case 'number': {
|
||||
let n = typeof value === 'number' ? value : Number(value);
|
||||
@@ -222,6 +267,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): FieldValue
|
||||
}
|
||||
case 'list':
|
||||
return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default;
|
||||
case 'struct':
|
||||
return structOf(def.fields, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaFor,
|
||||
type StructFieldDef,
|
||||
VARIABLE_KINDS,
|
||||
variableDef,
|
||||
type VariableKind,
|
||||
@@ -176,13 +177,28 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
|
||||
? (v.items as ListItemKind)
|
||||
: undefined;
|
||||
const def = variableDef(key, kind, undefined, items);
|
||||
const def = variableDef(key, kind, undefined, items, normalizeStructFields(v.fields));
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
|
||||
function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
const fields: StructFieldDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
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 (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue;
|
||||
seen.add(key);
|
||||
fields.push({ key, kind: f.kind as ListItemKind });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function normalizePorts(raw: unknown): DeclaredPort[] {
|
||||
const ports: DeclaredPort[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -3,17 +3,24 @@ import {
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextVariableName } from '../../core/commands/naming';
|
||||
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
|
||||
import {
|
||||
AddStructFieldCommand,
|
||||
AddVariableCommand,
|
||||
RemoveStructFieldCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameStructFieldCommand,
|
||||
RenameVariableCommand,
|
||||
} 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 {
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
type StructDef,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -21,15 +28,29 @@ import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import {
|
||||
createField,
|
||||
createFieldRow,
|
||||
type FieldHandlers,
|
||||
labelledRow,
|
||||
primitiveDef,
|
||||
structFieldValue,
|
||||
} from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { listOpener, previewingHandlers } from './previewHandlers';
|
||||
import {
|
||||
listOpener,
|
||||
type PreviewedValue,
|
||||
previewingHandlers,
|
||||
structFields,
|
||||
structFieldsOf,
|
||||
} from './previewHandlers';
|
||||
|
||||
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
list: 'List',
|
||||
struct: 'Struct',
|
||||
};
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the
|
||||
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
|
||||
@@ -38,16 +59,37 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
list: '≡',
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
|
||||
const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
};
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
const FIELD_KIND_MARKS: Record<ListItemKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
};
|
||||
|
||||
/**
|
||||
* Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a
|
||||
* struct – those are rows of their own, so adding one has to rebuild the section.
|
||||
*/
|
||||
export function assetSignature(project: Project, assetId: string): string {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset
|
||||
? schemaForAsset(asset)
|
||||
.map((d) => `${d.key}:${d.kind}`)
|
||||
.join(',')
|
||||
: 'missing';
|
||||
return asset ? schemaForAsset(asset).map(defSignature).join(',') : 'missing';
|
||||
}
|
||||
|
||||
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})` : ''}`;
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -122,17 +164,24 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */
|
||||
function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue<Asset> {
|
||||
return {
|
||||
target: () => findAsset(host.state.project, assetId),
|
||||
read: (asset) => asset.props[key],
|
||||
write: (asset, value) => (asset.props[key] = value),
|
||||
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
|
||||
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
|
||||
};
|
||||
}
|
||||
|
||||
/** Previews straight into the asset while editing; the command records the value from before the preview. */
|
||||
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
|
||||
const spec = assetSpec(host, assetId, def.key);
|
||||
return {
|
||||
...previewingHandlers<Asset>(host, {
|
||||
target: () => findAsset(host.state.project, assetId),
|
||||
read: (asset) => asset.props[def.key],
|
||||
write: (asset, value) => (asset.props[def.key] = value),
|
||||
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
|
||||
commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value),
|
||||
}),
|
||||
...previewingHandlers<Asset>(host, spec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
|
||||
...structFields<Asset>(host, def, spec),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +207,10 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
if (asset.variables.length === 0) addNote(host, 'No variables yet.');
|
||||
const values = resolveAssetProps(asset);
|
||||
for (const def of asset.variables) {
|
||||
if (def.kind === 'struct') {
|
||||
buildStructVariable(host, asset, def, values[def.key] ?? def.default);
|
||||
continue;
|
||||
}
|
||||
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' });
|
||||
@@ -185,3 +238,92 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
host.body.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* one level down and with a value column – a field is declared and given a default in one place.
|
||||
*/
|
||||
function buildStructVariable(
|
||||
host: InspectorHost,
|
||||
asset: BehaviorAsset,
|
||||
def: StructDef,
|
||||
value: PropertyValue,
|
||||
): void {
|
||||
const head = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
head.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(VARIABLE_KIND_MARKS.struct ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
head.add(mark);
|
||||
head.add(
|
||||
new TextField(def.key, {
|
||||
width: 90,
|
||||
onChange: (next) => host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())),
|
||||
}),
|
||||
);
|
||||
head.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = LIST_ITEM_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }),
|
||||
),
|
||||
}));
|
||||
new Menu(items).openBelow(add);
|
||||
};
|
||||
head.add(add);
|
||||
head.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
|
||||
}),
|
||||
);
|
||||
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 fields = new Map<string, (item: PropertyValue) => 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 fieldMark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' });
|
||||
fieldMark.minWidth = 18;
|
||||
row.add(fieldMark);
|
||||
row.add(
|
||||
new TextField(field.key, {
|
||||
width: 90,
|
||||
onChange: (next) =>
|
||||
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),
|
||||
);
|
||||
row.add(item.widget, item.grow ? 1 : 0);
|
||||
if (!item.grow) 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)),
|
||||
}),
|
||||
);
|
||||
fields.set(field.key, item.setValue);
|
||||
host.body.add(row);
|
||||
}
|
||||
|
||||
host.fields.set(def.key, {
|
||||
row: head,
|
||||
setValue: (next) => {
|
||||
for (const field of def.fields) fields.get(field.key)?.(structFieldValue(next, field));
|
||||
},
|
||||
setModified: () => {},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isList, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import {
|
||||
assetChoiceLabel,
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
assetRefLabel,
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
emptyField,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||
import {
|
||||
Button,
|
||||
@@ -22,6 +27,7 @@ import {
|
||||
TextField,
|
||||
Theme,
|
||||
ui,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
|
||||
@@ -41,6 +47,12 @@ export interface FieldHandlers {
|
||||
onChange: (value: PropertyValue) => void;
|
||||
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
|
||||
openList?: () => void;
|
||||
/**
|
||||
* Handlers for one field of a struct. Writing a field means writing the whole bundle, so only
|
||||
* the caller – which knows where that bundle lives – can build them. Without them a struct row
|
||||
* can only show what it holds.
|
||||
*/
|
||||
structFieldHandlers?: (field: StructFieldDef) => FieldHandlers;
|
||||
}
|
||||
|
||||
export interface FieldRow {
|
||||
@@ -169,6 +181,27 @@ interface Field {
|
||||
setValue(value: PropertyValue): void;
|
||||
}
|
||||
|
||||
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
|
||||
export function primitiveDef(key: string, kind: ListItemKind, label = key): PrimitiveDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return { key, label, kind: 'number', default: 0 };
|
||||
case 'text':
|
||||
return { key, label, kind: 'text', default: '' };
|
||||
case 'boolean':
|
||||
return { key, label, kind: 'boolean', default: false };
|
||||
case 'color':
|
||||
return { key, label, kind: 'color', default: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: ListItemKind }>;
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** The bare widget for a property definition (no label, no reset). */
|
||||
export function createField(
|
||||
ctx: FieldContext,
|
||||
@@ -203,6 +236,37 @@ export function createField(
|
||||
const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList });
|
||||
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
|
||||
}
|
||||
case 'struct': {
|
||||
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
|
||||
// one writes the whole bundle, which is why the handlers come per field from the caller.
|
||||
const per = handlers.structFieldHandlers;
|
||||
if (!per) {
|
||||
const summary = new Label(textOf(value), { style: 'muted' });
|
||||
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
|
||||
}
|
||||
const stack = new VStack({ gap: Theme.gap });
|
||||
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),
|
||||
);
|
||||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||||
if (!sub.grow) row.add(new Spacer(), 1);
|
||||
fields.set(field.key, sub);
|
||||
stack.add(row);
|
||||
}
|
||||
return {
|
||||
widget: stack,
|
||||
grow: true,
|
||||
setValue: (v) => {
|
||||
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'boolean': {
|
||||
const field = new Checkbox(Boolean(value));
|
||||
field.onChange = handlers.onChange;
|
||||
|
||||
@@ -40,10 +40,11 @@ import {
|
||||
import { Button, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { defSignature } from './assetSection';
|
||||
import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { parentRow } from './parentRow';
|
||||
import { listOpener, previewingHandlers } from './previewHandlers';
|
||||
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||
|
||||
@@ -65,12 +66,7 @@ export function objectSignature(
|
||||
: isLiveInstance(project, holder, object)
|
||||
? 'instance'
|
||||
: '';
|
||||
return (
|
||||
`${object.parentId}|${kind}|` +
|
||||
schemaForAsset(asset)
|
||||
.map((d) => `${d.key}:${d.kind}`)
|
||||
.join(',')
|
||||
);
|
||||
return `${object.parentId}|${kind}|` + schemaForAsset(asset).map(defSignature).join(',');
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
@@ -310,19 +306,21 @@ function propertyRow(
|
||||
values: Record<string, PropertyValue>,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||||
const overrideSpec: PreviewedValue<ObjectRef> = {
|
||||
target: () => currentRef(host.state, holderId, objectId),
|
||||
read: (ref) => ref.object.overrides[def.key],
|
||||
write: (ref, value) => ref.setOverride(def.key, value),
|
||||
remove: (ref) => ref.setOverride(def.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
|
||||
};
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
{
|
||||
...previewingHandlers<ObjectRef>(host, {
|
||||
target: () => currentRef(host.state, holderId, objectId),
|
||||
read: (ref) => ref.object.overrides[def.key],
|
||||
write: (ref, value) => ref.setOverride(def.key, value),
|
||||
remove: (ref) => ref.setOverride(def.key, undefined),
|
||||
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
|
||||
}),
|
||||
...previewingHandlers<ObjectRef>(host, overrideSpec),
|
||||
...listOpener(host.state, def, () => ({ kind: 'object', holderId, objectId, key: def.key })),
|
||||
...structFields<ObjectRef>(host, def, overrideSpec),
|
||||
},
|
||||
{
|
||||
extra: aspectActions(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
structOf,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
@@ -16,6 +22,34 @@ export function listOpener(
|
||||
return def.kind === 'list' ? { openList: () => state.openList(slot()) } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `structFieldHandlers` half of a row's handlers. Each field writes the whole bundle, so it
|
||||
* reads the bundle at that moment rather than from a snapshot: sharing one would make the second
|
||||
* field edited undo the first.
|
||||
*/
|
||||
export function structFields<Target>(
|
||||
host: { state: EditorState },
|
||||
def: PropertyDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
): Pick<FieldHandlers, 'structFieldHandlers'> {
|
||||
return def.kind === 'struct' ? { structFieldHandlers: structFieldsOf(host, def, spec) } : {};
|
||||
}
|
||||
|
||||
export function structFieldsOf<Target>(
|
||||
host: { state: EditorState },
|
||||
def: StructDef,
|
||||
spec: PreviewedValue<Target>,
|
||||
): (field: StructFieldDef) => FieldHandlers {
|
||||
return (field) =>
|
||||
previewingHandlers<Target>(host, {
|
||||
...spec,
|
||||
toModel: (input, target) => ({
|
||||
...structOf(def.fields, spec.read(target)),
|
||||
[field.key]: coerceListItem(input, field.kind),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Where an inspector row finds the value it edits, and what committing it means. */
|
||||
export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
/** The model object holding the value, or undefined when the selection is gone – then the row does nothing. */
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
* 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.
|
||||
*/
|
||||
import type { ListItemKind, PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
|
||||
import { createField, type FieldContext } from '../inspector/fieldFactory';
|
||||
import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory';
|
||||
import type { EditorState } from '../state';
|
||||
import { type ListAccess, resolveList } from './slot';
|
||||
|
||||
const INDEX_WIDTH = 40;
|
||||
|
||||
type ItemDef = Extract<PropertyDef, { kind: ListItemKind }>;
|
||||
|
||||
export class ListEditor extends VStack {
|
||||
private readonly ctx: FieldContext;
|
||||
private readonly title = new Label('', { style: 'bodyBold' });
|
||||
@@ -118,7 +115,12 @@ export class ListEditor extends VStack {
|
||||
const label = new Label(String(index), { style: 'small', align: 'right' });
|
||||
label.minWidth = INDEX_WIDTH;
|
||||
row.add(label);
|
||||
const field = createField(this.ctx, itemDef(access.def.items, index), item, access.itemHandlers(index));
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
primitiveDef(String(index), access.def.items),
|
||||
item,
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
@@ -135,21 +137,6 @@ export class ListEditor extends VStack {
|
||||
|
||||
private addItem(): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace([...access.value, itemDef(access.def.items, 0).default]);
|
||||
}
|
||||
}
|
||||
|
||||
/** One item as a property definition, so the inspector's own widget renders it. */
|
||||
function itemDef(items: ListItemKind, index: number): ItemDef {
|
||||
const key = String(index);
|
||||
switch (items) {
|
||||
case 'number':
|
||||
return { key, label: key, kind: 'number', default: 0 };
|
||||
case 'text':
|
||||
return { key, label: key, kind: 'text', default: '' };
|
||||
case 'boolean':
|
||||
return { key, label: key, kind: 'boolean', default: false };
|
||||
case 'color':
|
||||
return { key, label: key, kind: 'color', default: 0 };
|
||||
if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user