Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.
Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.
Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.
Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9d62dfea7b
commit
f60358c951
32 files changed
+1083
-253
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
declaresVariablePorts,
|
||||
type DeclaredPort,
|
||||
findNode,
|
||||
type Graph,
|
||||
@@ -190,6 +191,71 @@ export class RenamePortCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames one declared port on one node, taking its edges and its typed-in value along. */
|
||||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||||
const ports = node.ports ?? [];
|
||||
if (from === to || !ports.some((p) => p.key === from)) return;
|
||||
const site: PortSite = {
|
||||
graph,
|
||||
nodeId: node.id,
|
||||
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
|
||||
};
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
if (site.asInput) renameParam(site, from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Points one declared port at another variable. Key and kind change together, because on a
|
||||
* Get/Set variables node the kind is the variable's rather than a choice of its own; the edges
|
||||
* hanging on the port come along, so correcting a pick is not a rewiring.
|
||||
*/
|
||||
export class SetVariablePortCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous?: DeclaredPort;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
private readonly from: string,
|
||||
private readonly port: DeclaredPort,
|
||||
) {
|
||||
this.label = `Point ${from} at ${port.key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const ports = node.ports ?? [];
|
||||
const current = ports.find((p) => p.key === this.from);
|
||||
if (!current) throw new CommandError(`Port "${this.from}" not found`);
|
||||
if (this.from !== this.port.key && ports.some((p) => p.key === this.port.key))
|
||||
throw new CommandError(`"${this.port.key}" already exists`);
|
||||
this.previous = { ...current };
|
||||
this.swap(project, node, this.from, this.port);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.previous) return;
|
||||
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
|
||||
}
|
||||
|
||||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||||
renamePortOn(requireBehavior(project, this.assetId).graph, node, from, to.key);
|
||||
node.ports = (node.ports ?? []).map((p) => (p.key === to.key ? { ...p, kind: to.kind } : p));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a renamed variable into the nodes that name it as a port. Their edges and typed-in
|
||||
* values move with it – the port key *is* the variable name there.
|
||||
*/
|
||||
export function renameVariablePorts(graph: Graph, from: string, to: string): void {
|
||||
for (const node of graph.nodes) if (declaresVariablePorts(node.type)) renamePortOn(graph, node, from, to);
|
||||
}
|
||||
|
||||
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||||
export function nextPortName(node: GraphNode, base: string): string {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { renameVariablePorts } from './portCommands';
|
||||
import { requireBehavior } from './require';
|
||||
|
||||
/** The variable's default value is written to `props` as well. */
|
||||
@@ -133,6 +134,7 @@ export class RenameVariableCommand implements Command {
|
||||
}
|
||||
// Every node that picks a variable stores it under the param key `variable`.
|
||||
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
|
||||
renameVariablePorts(asset.graph, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,20 @@ import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
export const BEHAVIOR_START = 'BehaviorStart';
|
||||
export const BEHAVIOR_END = 'BehaviorEnd';
|
||||
export const RUN_BEHAVIOR = 'RunBehavior';
|
||||
export const GET_VARIABLES = 'GetVariables';
|
||||
export const SET_VARIABLES = 'SetVariables';
|
||||
|
||||
export const BEHAVIOR_PARAM = 'behavior';
|
||||
|
||||
/**
|
||||
* Nodes whose declared ports are not names of their own but variables of the behavior around them.
|
||||
* Renaming a variable therefore has to reach them, and the inspector picks their ports from a list
|
||||
* instead of letting them be typed.
|
||||
*/
|
||||
export function declaresVariablePorts(type: string): boolean {
|
||||
return type === GET_VARIABLES || type === SET_VARIABLES;
|
||||
}
|
||||
|
||||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||
export function isValidPortName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sameValue } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
@@ -10,7 +11,9 @@ export const IndexOf: NodeDef = {
|
||||
inputs: [list('list'), any('value')],
|
||||
outputs: [num('index')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({
|
||||
outputs: { index: asList(inputs.list).indexOf(asListItem(inputs.value)) },
|
||||
}),
|
||||
// Compared by content, not by identity: two equal structs are never the same object.
|
||||
run: (_ctx, inputs) => {
|
||||
const wanted = asListItem(inputs.value);
|
||||
return { outputs: { index: asList(inputs.list).findIndex((item) => sameValue(item, wanted)) } };
|
||||
},
|
||||
};
|
||||
@@ -82,7 +82,9 @@ import { Repeat } from './flow/Repeat';
|
||||
import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { GetVariable } from './variable/GetVariable';
|
||||
import { GetVariables } from './variable/GetVariables';
|
||||
import { SetVariable } from './variable/SetVariable';
|
||||
import { SetVariables } from './variable/SetVariables';
|
||||
|
||||
/**
|
||||
* Importing this module registers the built-in nodes. The palette sorts by category and label, so
|
||||
@@ -109,7 +111,9 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
BehaviorEnd,
|
||||
// variables
|
||||
GetVariable,
|
||||
GetVariables,
|
||||
SetVariable,
|
||||
SetVariables,
|
||||
// data: references and properties
|
||||
Self,
|
||||
Owner,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { GET_VARIABLES } from '../../nested';
|
||||
import type { Value } from '../../values';
|
||||
|
||||
/**
|
||||
* The plural of Get variable: one output per variable picked, so a graph reading a handful of them
|
||||
* is one node instead of a column of five. The ports are declared on the node, the way Expand
|
||||
* declares the fields of a struct.
|
||||
*/
|
||||
export const GetVariables: NodeDef = {
|
||||
type: GET_VARIABLES,
|
||||
label: 'Get variables',
|
||||
category: 'variable',
|
||||
description: "Reads several of this behavior's variables at once, one output per variable.",
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
declaresPorts: 'outputs',
|
||||
run: (ctx, _inputs, _params, def) => {
|
||||
const outputs: Record<string, Value> = {};
|
||||
for (const port of def.outputs) outputs[port.key] = ctx.variables[port.key] ?? null;
|
||||
return { outputs };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { SET_VARIABLES } from '../../nested';
|
||||
import { writeTyped } from '../../values';
|
||||
import { exec } from '../shared';
|
||||
|
||||
/**
|
||||
* The plural of Set variable: one input per variable picked. An input left unconnected writes the
|
||||
* value typed on the node, exactly as it does on the singular node – the node writes every variable
|
||||
* it lists, every time it runs.
|
||||
*/
|
||||
export const SetVariables: NodeDef = {
|
||||
type: SET_VARIABLES,
|
||||
label: 'Set variables',
|
||||
category: 'variable',
|
||||
description: "Writes several of this behavior's variables at once, one input per variable.",
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
declaresPorts: 'inputs',
|
||||
run: (ctx, inputs, _params, def) => {
|
||||
for (const port of def.inputs)
|
||||
if (port.type !== 'exec') writeTyped(ctx.variables, port.key, inputs[port.key]);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -39,16 +39,21 @@ export function asList(value: Value | undefined): ListValue {
|
||||
return [value];
|
||||
}
|
||||
|
||||
/** A value narrowed to what a list may hold: handles and nested lists have no stored form. */
|
||||
export function asListItem(value: Value | undefined): number | string | boolean {
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return 0;
|
||||
return asString(value);
|
||||
/** A value narrowed to what a list may hold: a struct stays one, handles and lists flatten. */
|
||||
export function asListItem(value: Value | undefined): number | string | boolean | StructValue {
|
||||
if (isStructValue(value)) return value;
|
||||
return asPrimitive(value);
|
||||
}
|
||||
|
||||
/** A value narrowed to what a struct field may hold: a list stays a list, anything deeper flattens. */
|
||||
export function asField(value: Value | undefined): FieldValue {
|
||||
return isList(value) ? value : asListItem(value);
|
||||
return isList(value) ? value : asPrimitive(value);
|
||||
}
|
||||
|
||||
function asPrimitive(value: Value | undefined): number | string | boolean {
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value;
|
||||
if (value === null || value === undefined) return 0;
|
||||
return asString(value);
|
||||
}
|
||||
|
||||
export function asBoolean(value: Value | undefined): boolean {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* A list is deliberately flat – a list of lists would need a nested editor and a deep compare on
|
||||
* every override, and nothing so far wants one: a board is addressed as `row · width + column`.
|
||||
* A list holds primitives or structs. Depth stops there and it stops in the declaration, not by
|
||||
* convention: a list says what its items are (`ListItemKind`, struct included), a struct field
|
||||
* says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest
|
||||
* a stored value goes, which is what keeps the deep compare and the editor finite.
|
||||
*/
|
||||
export type ListValue = readonly (number | string | boolean)[];
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** What a struct field may hold: no field is itself a struct, for the same reason a list is flat. */
|
||||
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
|
||||
export type FieldValue = number | string | boolean | ListValue;
|
||||
|
||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||
@@ -30,7 +32,7 @@ export const isStruct = (value: unknown): value is StructValue =>
|
||||
*/
|
||||
export function textOf(value: PropertyValue | undefined): string {
|
||||
if (value === undefined) return '';
|
||||
if (isList(value)) return value.join(', ');
|
||||
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
|
||||
if (isStruct(value))
|
||||
return Object.entries(value)
|
||||
.map(([field, item]) => `${field}: ${textOf(item)}`)
|
||||
@@ -42,12 +44,11 @@ export function textOf(value: PropertyValue | undefined): string {
|
||||
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||
if (isList(a) || isList(b)) {
|
||||
if (!isList(a) || !isList(b) || a.length !== b.length) return false;
|
||||
return a.every((item, i) => item === b[i]);
|
||||
return a.every((item, i) => sameValue(item, b[i]));
|
||||
}
|
||||
if (isStruct(a) || isStruct(b)) {
|
||||
if (!isStruct(a) || !isStruct(b)) return false;
|
||||
const keys = Object.keys(a);
|
||||
// Fields are never structs, so this recurses exactly one level deep.
|
||||
return keys.length === Object.keys(b).length && keys.every((key) => sameValue(a[key], b[key]));
|
||||
}
|
||||
return a === b;
|
||||
|
||||
@@ -49,20 +49,32 @@ 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;
|
||||
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
|
||||
fields?: readonly StructFieldDef[];
|
||||
}
|
||||
/**
|
||||
* `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;
|
||||
/** What a struct field holds. No `struct`: 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. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] 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;
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
@@ -200,14 +212,19 @@ export function variableDef(
|
||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||
case 'text':
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'list':
|
||||
case 'list': {
|
||||
const item = items ?? 'number';
|
||||
const shape = item === 'struct' ? (fields ?? []) : undefined;
|
||||
const raw = isList(defaultValue) ? defaultValue : [];
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
kind,
|
||||
default: isList(defaultValue) ? defaultValue : [],
|
||||
items: items ?? 'number',
|
||||
default: raw.map((entry) => coerceListItem(entry, item, shape)),
|
||||
items: item,
|
||||
...(shape ? { fields: shape } : {}),
|
||||
};
|
||||
}
|
||||
case 'struct': {
|
||||
const declared = fields ?? [];
|
||||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||||
@@ -216,14 +233,14 @@ export function variableDef(
|
||||
}
|
||||
|
||||
/** 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> = {
|
||||
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
number: 0,
|
||||
text: '',
|
||||
boolean: false,
|
||||
color: 0,
|
||||
};
|
||||
|
||||
export function emptyField(kind: ListItemKind): number | string | boolean {
|
||||
export function emptyField(kind: FieldKind): number | string | boolean {
|
||||
return EMPTY_FIELD[kind];
|
||||
}
|
||||
|
||||
@@ -236,7 +253,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 ? coerceListItem(source[field.key]!, field.kind) : emptyField(field.kind);
|
||||
field.key in source ? coerceField(source[field.key]!, field.kind) : emptyField(field.kind);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -266,14 +283,24 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
return def.options.some((o) => o.value === s) ? s : def.default;
|
||||
}
|
||||
case 'list':
|
||||
return isList(value) ? value.map((item) => coerceListItem(item, def.items)) : def.default;
|
||||
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
|
||||
case 'struct':
|
||||
return structOf(def.fields, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function coerceListItem(value: PropertyValue, items: ListItemKind): number | string | boolean {
|
||||
if (items === 'text') return isList(value) ? value.join(', ') : textOf(value);
|
||||
/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */
|
||||
export function coerceListItem(
|
||||
value: PropertyValue,
|
||||
items: ListItemKind,
|
||||
fields?: readonly StructFieldDef[],
|
||||
): number | string | boolean | StructValue {
|
||||
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, 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);
|
||||
if (items === 'boolean') {
|
||||
if (typeof value === 'boolean') return value;
|
||||
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
|
||||
|
||||
+27
-17
@@ -13,7 +13,7 @@ import {
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
import type { FieldValue, PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { FieldValue, PropertyBag, PropertyValue, StructValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
@@ -21,6 +21,8 @@ import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
coerceValue,
|
||||
COMMON_PROPS,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
isCommonProp,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
@@ -192,9 +194,9 @@ 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 (!(LIST_ITEM_KINDS as readonly string[]).includes(String(f.kind))) continue;
|
||||
if (!(FIELD_KINDS as readonly string[]).includes(String(f.kind))) continue;
|
||||
seen.add(key);
|
||||
fields.push({ key, kind: f.kind as ListItemKind });
|
||||
fields.push({ key, kind: f.kind as FieldKind });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
@@ -317,27 +319,35 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
|
||||
*/
|
||||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||
const field = asFieldValue(value);
|
||||
if (field !== undefined) return field;
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if (isPrimitive(value)) return value;
|
||||
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
|
||||
return asStoredStruct(value, asStoredField);
|
||||
}
|
||||
|
||||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||||
|
||||
/** A list item: a primitive or a struct of primitives. */
|
||||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||||
isPrimitive(value) ? value : asStoredStruct(value, (item) => (isPrimitive(item) ? item : undefined));
|
||||
|
||||
/** A struct field: a primitive or a flat list. A field that is itself a struct is dropped. */
|
||||
const asStoredField = (value: unknown): FieldValue | undefined =>
|
||||
isPrimitive(value) ? value : Array.isArray(value) ? value.filter(isPrimitive) : undefined;
|
||||
|
||||
function asStoredStruct(
|
||||
value: unknown,
|
||||
ofField: (item: unknown) => FieldValue | undefined,
|
||||
): StructValue | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const fields: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const stored = asFieldValue(item);
|
||||
const stored = ofField(item);
|
||||
if (stored !== undefined) fields[key] = stored;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function asFieldValue(value: unknown): FieldValue | undefined {
|
||||
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (Array.isArray(value))
|
||||
return value.filter(
|
||||
(item): item is number | string | boolean =>
|
||||
typeof item === 'number' || typeof item === 'string' || typeof item === 'boolean',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
|
||||
@@ -226,7 +226,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
list: {
|
||||
info: () => editor.listEditor.debugInfo(),
|
||||
/** Where an item's widget sits, so a check can click it – a number field has no findable text. */
|
||||
fieldAt: (index: number) => editor.listEditor.fieldAt(index),
|
||||
fieldAt: (index: number, field = '') => editor.listEditor.fieldAt(index, field),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
playInfo: () => editor.playInfo(),
|
||||
|
||||
@@ -16,8 +16,8 @@ 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,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
type StructDef,
|
||||
@@ -62,7 +62,7 @@ const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
|
||||
const FIELD_KIND_LABELS: Record<FieldKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
@@ -71,7 +71,7 @@ const FIELD_KIND_LABELS: Record<ListItemKind, string> = {
|
||||
|
||||
const FIELD_INDENT = 12;
|
||||
|
||||
const FIELD_KIND_MARKS: Record<ListItemKind, string> = {
|
||||
const FIELD_KIND_MARKS: Record<FieldKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
@@ -264,7 +264,7 @@ function buildStructVariable(
|
||||
head.add(new Spacer(), 1);
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = LIST_ITEM_KINDS.map((kind) => ({
|
||||
const items = FIELD_KINDS.map((kind) => ({
|
||||
label: FIELD_KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../../core/properties/assetRef';
|
||||
import {
|
||||
emptyField,
|
||||
type ListItemKind,
|
||||
type FieldKind,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -179,10 +179,12 @@ interface Field {
|
||||
/** Whether the widget should take the remaining row width. */
|
||||
grow: boolean;
|
||||
setValue(value: PropertyValue): void;
|
||||
/** The per-field widgets of a struct, so a caller can address one of them. */
|
||||
parts?: ReadonlyMap<string, Widget>;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return { key, label, kind: 'number', default: 0 };
|
||||
@@ -195,7 +197,7 @@ export function primitiveDef(key: string, kind: ListItemKind, label = key): Prim
|
||||
}
|
||||
}
|
||||
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: ListItemKind }>;
|
||||
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||||
|
||||
/** 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 {
|
||||
@@ -265,6 +267,7 @@ export function createField(
|
||||
setValue: (v) => {
|
||||
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
|
||||
},
|
||||
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
||||
};
|
||||
}
|
||||
case 'boolean': {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import {
|
||||
callProblem,
|
||||
declaresVariablePorts,
|
||||
type Graph,
|
||||
edgesInto,
|
||||
type GraphNode,
|
||||
@@ -16,7 +17,7 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildPortSection } from './portSection';
|
||||
import { buildPortSection, buildVariablePortSection } from './portSection';
|
||||
import { listOpener, previewingHandlers } from './previewHandlers';
|
||||
|
||||
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
|
||||
@@ -51,7 +52,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
addNote(host, def.description);
|
||||
const problem = callProblem(node, behaviorLookup(host.state.project), assetId);
|
||||
if (problem) addNote(host, problem, 'warning');
|
||||
if (def.declaresPorts) buildPortSection(host, assetId, node);
|
||||
if (declaresVariablePorts(node.type)) buildVariablePortSection(host, assetId, node, asset.variables);
|
||||
else if (def.declaresPorts) buildPortSection(host, assetId, node);
|
||||
|
||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) {
|
||||
|
||||
@@ -3,15 +3,18 @@ import {
|
||||
nextPortName,
|
||||
RemovePortCommand,
|
||||
RenamePortCommand,
|
||||
SetVariablePortCommand,
|
||||
} from '../../core/commands/portCommands';
|
||||
import {
|
||||
BEHAVIOR_START,
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type DeclaredPortKind,
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
} from '../../core/graph';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
|
||||
const KIND_LABELS: Record<DeclaredPortKind, string> = {
|
||||
@@ -32,6 +35,75 @@ const KIND_MARKS: Record<DeclaredPortKind, string> = {
|
||||
struct: '⊞',
|
||||
};
|
||||
|
||||
/**
|
||||
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
|
||||
* from that list rather than named, and its kind follows the variable's declaration.
|
||||
*/
|
||||
export function buildVariablePortSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
node: GraphNode,
|
||||
variables: readonly PropertyDef[],
|
||||
): void {
|
||||
const ports = node.ports ?? [];
|
||||
const taken = new Set(ports.map((p) => p.key));
|
||||
const portOf = (v: PropertyDef): DeclaredPort => ({ key: v.key, kind: variablePortKind(v) });
|
||||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||||
variables.map((v) => ({
|
||||
label: v.key,
|
||||
disabled: taken.has(v.key) && v.key !== own,
|
||||
onSelect: () => onPick(portOf(v)),
|
||||
}));
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||
const add = new Dropdown('+ Variable', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||||
new Menu(items).openBelow(add);
|
||||
};
|
||||
add.setEnabled(variables.length > taken.size);
|
||||
header.add(add);
|
||||
host.body.add(header);
|
||||
|
||||
if (variables.length === 0) addNote(host, 'This behavior has no variables yet.');
|
||||
else if (ports.length === 0) addNote(host, 'No variables picked yet. Each one adds a port.');
|
||||
|
||||
for (const port of ports) {
|
||||
const known = variables.some((v) => v.key === port.key);
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||||
choose.onClick = () => {
|
||||
const items = pick(port.key, (next) =>
|
||||
host.state.execute(new SetVariablePortCommand(assetId, node.id, port.key, next)),
|
||||
);
|
||||
new Menu(items).openBelow(choose);
|
||||
};
|
||||
row.add(choose, 1);
|
||||
row.add(new Spacer());
|
||||
row.add(
|
||||
new Button('×', {
|
||||
variant: 'ghost',
|
||||
width: Theme.controlHeight,
|
||||
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
|
||||
}),
|
||||
);
|
||||
host.body.add(row);
|
||||
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
/** Every variable kind is a port kind too; the fallback is for a project file that says otherwise. */
|
||||
function variablePortKind(def: PropertyDef): DeclaredPortKind {
|
||||
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
|
||||
? (def.kind as DeclaredPortKind)
|
||||
: 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
|
||||
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
coerceListItem,
|
||||
coerceField,
|
||||
type PropertyDef,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
@@ -45,7 +45,7 @@ export function structFieldsOf<Target>(
|
||||
...spec,
|
||||
toModel: (input, target) => ({
|
||||
...structOf(def.fields, spec.read(target)),
|
||||
[field.key]: coerceListItem(input, field.kind),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* tab, so what the list belongs to is still on screen while it is edited.
|
||||
*/
|
||||
import { Button, HStack, Label, ScrollContainer, Spacer, Theme, VStack, type Widget } from '../../ui';
|
||||
import { createField, type FieldContext, primitiveDef } from '../inspector/fieldFactory';
|
||||
import { textOf, type ListValue, type PropertyValue } from '../../core/model/Properties';
|
||||
import { type StructDef, structOf } from '../../core/properties/PropertySchema';
|
||||
import { createField, type FieldContext, primitiveDef, type PrimitiveDef } from '../inspector/fieldFactory';
|
||||
import type { EditorState } from '../state';
|
||||
import { type ListAccess, resolveList } from './slot';
|
||||
import { type ListAccess, type ListDef, resolveList } from './slot';
|
||||
|
||||
const INDEX_WIDTH = 40;
|
||||
|
||||
@@ -19,8 +21,8 @@ export class ListEditor extends VStack {
|
||||
private rows = new VStack({ padding: Theme.padding, gap: Theme.gap });
|
||||
/** The structure the rows were built for; only a change here may destroy a row being typed in. */
|
||||
private signature = '';
|
||||
private setters: ((value: number | string | boolean) => void)[] = [];
|
||||
private fields: Widget[] = [];
|
||||
private setters: ((value: PropertyValue) => void)[] = [];
|
||||
private fields: { widget: Widget; parts?: ReadonlyMap<string, Widget> }[] = [];
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ gap: 0 });
|
||||
@@ -48,8 +50,9 @@ export class ListEditor extends VStack {
|
||||
}
|
||||
|
||||
/** For the check scripts: where an item's widget sits, since a number field carries no findable text. */
|
||||
fieldAt(index: number): { x: number; y: number } | null {
|
||||
const widget = this.fields[index];
|
||||
fieldAt(index: number, field = ''): { x: number; y: number } | null {
|
||||
const found = this.fields[index];
|
||||
const widget = field ? found?.parts?.get(field) : found?.widget;
|
||||
if (!widget) return null;
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
@@ -61,7 +64,7 @@ export class ListEditor extends VStack {
|
||||
if (!access) return { title: '', items: [], kind: '' };
|
||||
return {
|
||||
title: access.title,
|
||||
items: access.value.map((item) => String(item)),
|
||||
items: access.value.map((item) => textOf(item)),
|
||||
kind: access.def.items,
|
||||
};
|
||||
}
|
||||
@@ -71,9 +74,11 @@ export class ListEditor extends VStack {
|
||||
return slot ? resolveList(this.state, slot) : null;
|
||||
}
|
||||
|
||||
/** Everything that decides how many rows there are and of what kind. */
|
||||
/** Everything that decides how many rows there are and of what kind – a struct's fields included. */
|
||||
private signatureOf(access: ListAccess | null): string {
|
||||
return access ? `${access.title}|${access.def.items}|${access.value.length}` : '';
|
||||
if (!access) return '';
|
||||
const fields = (access.def.fields ?? []).map((f) => `${f.key}:${f.kind}`).join(',');
|
||||
return `${access.title}|${access.def.items}|${fields}|${access.value.length}`;
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
@@ -109,18 +114,16 @@ export class ListEditor extends VStack {
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
private itemRow(access: ListAccess, item: number | string | boolean, index: number): HStack {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
private itemRow(access: ListAccess, item: ListValue[number], index: number): HStack {
|
||||
// A struct item is a stack of field rows: its row grows with it and aligns to the top.
|
||||
const tall = access.def.items === 'struct';
|
||||
const row = new HStack({ gap: Theme.gap, align: tall ? 'start' : 'center' });
|
||||
if (!tall) row.minHeight = Theme.rowHeight;
|
||||
const label = new Label(String(index), { style: 'small', align: 'right' });
|
||||
label.minWidth = INDEX_WIDTH;
|
||||
if (tall) label.minHeight = Theme.rowHeight;
|
||||
row.add(label);
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
primitiveDef(String(index), access.def.items),
|
||||
item,
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
const field = createField(this.ctx, itemDef(access.def, index), item, access.itemHandlers(index));
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
@@ -131,12 +134,20 @@ export class ListEditor extends VStack {
|
||||
}),
|
||||
);
|
||||
this.setters.push(field.setValue);
|
||||
this.fields.push(field.widget);
|
||||
this.fields.push({ widget: field.widget, parts: field.parts });
|
||||
return row;
|
||||
}
|
||||
|
||||
private addItem(): void {
|
||||
const access = this.access();
|
||||
if (access) access.replace([...access.value, primitiveDef('0', access.def.items).default]);
|
||||
if (access) access.replace([...access.value, itemDef(access.def, access.value.length).default]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Item `index` as a definition, so the inspector's own widgets render a row of the list. */
|
||||
function itemDef(def: ListDef, index: number): PrimitiveDef | StructDef {
|
||||
const key = String(index);
|
||||
return def.items === 'struct'
|
||||
? { key, label: key, kind: 'struct', fields: def.fields ?? [], default: structOf(def.fields ?? [], {}) }
|
||||
: primitiveDef(key, def.items);
|
||||
}
|
||||
+34
-5
@@ -12,13 +12,16 @@ import { resolveDef } from '../../core/graph/nested';
|
||||
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 } from '../../core/model/Properties';
|
||||
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
coerceListItem,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||||
@@ -53,9 +56,13 @@ const listDefIn = (schema: PropertySchema, key: string): ListDef | null => {
|
||||
return def?.kind === 'list' ? def : null;
|
||||
};
|
||||
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean): ListValue =>
|
||||
const withItem = (list: ListValue, index: number, item: number | string | boolean | StructValue): ListValue =>
|
||||
list.map((value, i) => (i === index ? item : value));
|
||||
|
||||
/** The item at `index` in the shape the declaration promises, so a field write never invents one. */
|
||||
const structItemAt = (list: ListValue, index: number, def: ListDef): StructValue =>
|
||||
structOf(def.fields ?? [], list[index]);
|
||||
|
||||
/**
|
||||
* `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
|
||||
@@ -72,11 +79,33 @@ function access<Target>(
|
||||
def,
|
||||
title,
|
||||
value: current(),
|
||||
itemHandlers: (index) =>
|
||||
previewingHandlers<Target>(
|
||||
itemHandlers: (index) => ({
|
||||
...previewingHandlers<Target>(
|
||||
{ state },
|
||||
{ ...spec, toModel: (input) => withItem(current(), index, coerceListItem(input, def.items)) },
|
||||
{
|
||||
...spec,
|
||||
toModel: (input) => withItem(current(), index, coerceListItem(input, def.items, def.fields)),
|
||||
},
|
||||
),
|
||||
// A struct item is edited field by field, and each write rebuilds the whole item from what
|
||||
// is in the list at that moment -- the same rule as a row, one level deeper.
|
||||
...(def.items === 'struct'
|
||||
? {
|
||||
structFieldHandlers: (field: StructFieldDef) =>
|
||||
previewingHandlers<Target>(
|
||||
{ state },
|
||||
{
|
||||
...spec,
|
||||
toModel: (input) =>
|
||||
withItem(current(), index, {
|
||||
...structItemAt(current(), index, def),
|
||||
[field.key]: coerceField(input, field.kind),
|
||||
}),
|
||||
},
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
|
||||
// A preview does not, which is why `toModel` above has to.
|
||||
replace: (next) => state.execute(spec.commit(next)),
|
||||
|
||||
Reference in new issue
Block a user