Describe every value kind in one table instead of spreading it over the code
A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a7185948d
commit
3ed8b68ecf
39 files changed
+699
-565
No files matched your search
@@ -140,14 +140,19 @@ shown on the node and editable in the inspector.
|
||||
neighbouring item touched. A **struct item** carries its own shape the same way: its fields are
|
||||
renamed, retyped, added (`+ Field ▾`) and removed on its own rows, which grows that one item and
|
||||
not the list. Two kinds need saying, because JSON cannot tell them from the value beside them: a
|
||||
**colour** is a number (`{"$color": 16711680}`) and a **texture** is an id, which is a text
|
||||
(`{"$texture": "walk-up"}`, the picker a sprite's `texture` property has – a whole picture, one
|
||||
frame of a sheet, or an animation). A field name cannot start with `$` (`isValidFieldName`), so a
|
||||
tag can never collide with a struct item of one field. Either reads as what it wraps on a data
|
||||
**colour** is a number and a **texture** is an id, which is a text. Such a *tagged* kind wears its
|
||||
name as the one key of a small object – `{"$color": 16711680}`, `{"$texture": "walk-up"}` (the
|
||||
picker a sprite's `texture` property has – a whole picture, one frame of a sheet, or an animation).
|
||||
A field name cannot start with `$` (`isValidFieldName`), so a tag can never collide with a struct
|
||||
item of one field. Either reads as what it wraps on a data
|
||||
edge (`Item at`), the graph having neither colours nor pictures of its own, and `Set item` leaves
|
||||
the slot the kind it was. A **texture is a kind wherever a kind is picked** – a variable, a struct
|
||||
field, a list item, an Any port of `Properties`, a field of `Pack` – and the `Texture` node hands
|
||||
one into the graph, as `Number` and `Text` do a number and a text. Declared (a variable, a struct
|
||||
one into the graph, as `Number` and `Text` do a number and a text. **Every kind is one row of one
|
||||
table** (`core/properties/kinds.ts`): its word and mark, what it travels as on an edge, its empty
|
||||
value, how a value is read as it, whether it is tagged and where it may be picked. The kind lists,
|
||||
the `+ …` menus, the tags, the port fields and the literal nodes all follow from the row, so a new
|
||||
kind is that row plus, if it names something in the project, its picker. Declared (a variable, a struct
|
||||
field of one, a port) it needs no tag: the declaration says what it is, and the value is the id –
|
||||
the way a colour field is a number. A declared port of kind colour or texture carries the field
|
||||
that edits it and travels as a number or a text, since the graph has neither; a port declared as
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
} from '../model/Properties';
|
||||
import { overrideSites } from '../model/instance';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { VariableKind } from '../properties/kinds';
|
||||
import {
|
||||
emptyFieldValue,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
type VariableKind,
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
|
||||
+5
-16
@@ -1,4 +1,7 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { type DeclaredPortKind, KIND } from '../properties/kinds';
|
||||
|
||||
export type { DeclaredPortKind } from '../properties/kinds';
|
||||
|
||||
/** `exec` carries control flow, the others values; `any` accepts every value type. */
|
||||
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
|
||||
@@ -28,7 +31,7 @@ export interface GraphNode {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
|
||||
/** A port a start/end node declares. `kind` travels as the kind table says (`text` → string). */
|
||||
export interface DeclaredPort {
|
||||
key: string;
|
||||
kind: DeclaredPortKind;
|
||||
@@ -36,22 +39,8 @@ export interface DeclaredPort {
|
||||
write?: true;
|
||||
}
|
||||
|
||||
/** A color travels as a number and a texture as text; the kind says which field edits the value. */
|
||||
export const DECLARED_PORT_KINDS = [
|
||||
'number',
|
||||
'boolean',
|
||||
'text',
|
||||
'color',
|
||||
'texture',
|
||||
'object',
|
||||
'list',
|
||||
'struct',
|
||||
] as const;
|
||||
export type DeclaredPortKind = (typeof DECLARED_PORT_KINDS)[number];
|
||||
|
||||
export function declaredPortType(kind: DeclaredPortKind): PortType {
|
||||
if (kind === 'text' || kind === 'texture') return 'string';
|
||||
return kind === 'color' ? 'number' : kind;
|
||||
return KIND[kind].travels;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import { isKindWith, plainKindOf } from '../properties/kinds';
|
||||
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import type { NodeContext } from './NodeContext';
|
||||
import { asBoolean, asList, asNumber, asString, type Value } from './values';
|
||||
import { asList, type Value } from './values';
|
||||
|
||||
export interface PortDef {
|
||||
key: string;
|
||||
@@ -142,23 +143,11 @@ export function withInlineDefault(port: PortDef): PortDef {
|
||||
|
||||
export function inlineInputDef(port: PortDef): PropertyDef {
|
||||
if (port.field) return { ...port.field, key: port.key, label: port.label };
|
||||
switch (port.type) {
|
||||
case 'boolean':
|
||||
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||||
case 'list':
|
||||
return { key: port.key, label: port.label, kind: 'list', default: asList(port.default) };
|
||||
default:
|
||||
return {
|
||||
key: port.key,
|
||||
label: port.label,
|
||||
kind: 'number',
|
||||
default: asNumber(port.default ?? 0),
|
||||
step: 1,
|
||||
};
|
||||
}
|
||||
const kind = port.type === 'any' ? 'text' : plainKindOf(port.type);
|
||||
if (kind === 'list') return { key: port.key, label: port.label, kind, default: asList(port.default) };
|
||||
const flat = kind !== undefined && isKindWith('field', kind) ? kind : 'number';
|
||||
const def = flatDef(port.key, flat, { label: port.label, value: port.default });
|
||||
return def.kind === 'number' ? { ...def, step: 1 } : def;
|
||||
}
|
||||
|
||||
/** The value an unconnected input carries: the node's own inline value, else the port default. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { isValidFieldName } from '../properties/PropertySchema';
|
||||
import { isTaggedKind } from '../properties/kinds';
|
||||
import { flatDef, isValidFieldName } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
@@ -75,14 +76,10 @@ export function isTerminalNode(node: GraphNode): boolean {
|
||||
return isTerminalType(node.type);
|
||||
}
|
||||
|
||||
/** A color or texture port carries the field that edits it; the graph itself sees a number or a text. */
|
||||
/** A tagged kind is one the value cannot show, so its port carries the field that edits it. */
|
||||
export function portDefOf(port: DeclaredPort): PortDef {
|
||||
const def: PortDef = { key: port.key, label: port.key, type: declaredPortType(port.kind) };
|
||||
if (port.kind === 'color')
|
||||
return { ...def, field: { key: port.key, label: port.key, kind: 'color', default: 0 } };
|
||||
if (port.kind === 'texture')
|
||||
return { ...def, field: { key: port.key, label: port.key, kind: 'texture', default: '' } };
|
||||
return def;
|
||||
return isTaggedKind(port.kind) ? { ...def, field: flatDef(port.key, port.kind) } : def;
|
||||
}
|
||||
|
||||
/** The ports the start (or end) node of `graph` declares, as seen from a node calling it. */
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean } from '../../values';
|
||||
import { bool } from '../shared';
|
||||
|
||||
export const BooleanLiteral: NodeDef = {
|
||||
type: 'Boolean',
|
||||
label: 'Boolean',
|
||||
category: 'data',
|
||||
description: 'A constant true/false.',
|
||||
inputs: [],
|
||||
outputs: [bool('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asBoolean(params.value) } }),
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { sameValue } from '../../../model/Properties';
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import { sameValue, untag } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asListItem } from '../../values';
|
||||
import { any, list, num } from '../shared';
|
||||
@@ -15,9 +14,9 @@ export const IndexOf: NodeDef = {
|
||||
// Compared by content, not by identity: two equal structs are never the same object. A color item
|
||||
// is compared as its number, or searching a list of colors with one would never find it.
|
||||
run: (_ctx, inputs) => {
|
||||
const wanted = itemValue(asListItem(inputs.value));
|
||||
const wanted = untag(asListItem(inputs.value));
|
||||
return {
|
||||
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(itemValue(item), wanted)) },
|
||||
outputs: { index: asList(inputs.list).findIndex((item) => sameValue(untag(item), wanted)) },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { itemValue } from '../../../properties/listItems';
|
||||
import { untag } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList, asNumber } from '../../values';
|
||||
import { any, int, list } from '../shared';
|
||||
@@ -14,7 +14,7 @@ export const ItemAt: NodeDef = {
|
||||
run: (_ctx, inputs) => {
|
||||
const items = asList(inputs.list);
|
||||
const i = Math.floor(asNumber(inputs.index));
|
||||
return { outputs: { item: itemValue(items[i]) ?? 0 } };
|
||||
return { outputs: { item: untag(items[i]) ?? 0 } };
|
||||
},
|
||||
params: [],
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asList } from '../../values';
|
||||
import { list } from '../shared';
|
||||
|
||||
export const ListLiteral: NodeDef = {
|
||||
type: 'List',
|
||||
label: 'List',
|
||||
category: 'data',
|
||||
description: 'A constant list, edited item by item in the List tab – each item with its own kind.',
|
||||
inputs: [],
|
||||
outputs: [list('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'list', default: [] }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asList(params.value) } }),
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { num } from '../shared';
|
||||
|
||||
export const NumberLiteral: NodeDef = {
|
||||
type: 'Number',
|
||||
label: 'Number',
|
||||
category: 'data',
|
||||
description: 'A constant number.',
|
||||
inputs: [],
|
||||
outputs: [num('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asNumber(params.value) } }),
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { str } from '../shared';
|
||||
|
||||
export const StringLiteral: NodeDef = {
|
||||
type: 'String',
|
||||
label: 'Text',
|
||||
category: 'data',
|
||||
description: 'A constant text.',
|
||||
inputs: [],
|
||||
outputs: [str('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asString } from '../../values';
|
||||
import { str } from '../shared';
|
||||
|
||||
/** The picture is its id on the edge: a whole texture, one frame of a sheet or an animation. */
|
||||
export const TextureLiteral: NodeDef = {
|
||||
type: 'Texture',
|
||||
label: 'Texture',
|
||||
category: 'data',
|
||||
description: 'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
|
||||
inputs: [],
|
||||
outputs: [str('value')],
|
||||
params: [{ key: 'value', label: 'Value', kind: 'texture', default: '' }],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value) } }),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { KIND, kindLabel, type VariableKind } from '../../../properties/kinds';
|
||||
import { coerceValue, flatDef, type PropertyDef } from '../../../properties/PropertySchema';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
|
||||
/** A node handing out one constant of a kind: its one setting, edited as that kind, is its output. */
|
||||
function literal(type: string, kind: Exclude<VariableKind, 'struct'>, description: string): NodeDef {
|
||||
const value: PropertyDef =
|
||||
kind === 'list'
|
||||
? { key: 'value', label: 'Value', kind, default: [] }
|
||||
: kind === 'number'
|
||||
? { ...flatDef('value', kind, { label: 'Value' }), step: 1 }
|
||||
: flatDef('value', kind, { label: 'Value' });
|
||||
return {
|
||||
type,
|
||||
label: kindLabel(kind),
|
||||
category: 'data',
|
||||
description,
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: KIND[kind].travels }],
|
||||
params: [value],
|
||||
run: (_ctx, _inputs, params) => ({ outputs: { value: coerceValue(value, params.value ?? value.default) } }),
|
||||
};
|
||||
}
|
||||
|
||||
export const NumberLiteral = literal('Number', 'number', 'A constant number.');
|
||||
export const BooleanLiteral = literal('Boolean', 'boolean', 'A constant true/false.');
|
||||
export const StringLiteral = literal('String', 'text', 'A constant text.');
|
||||
export const TextureLiteral = literal(
|
||||
'Texture',
|
||||
'texture',
|
||||
'A constant picture: a texture, one of its frames or an animation, as a sprite wears it.',
|
||||
);
|
||||
export const ListLiteral = literal(
|
||||
'List',
|
||||
'list',
|
||||
'A constant list, edited item by item in the List tab – each item with its own kind.',
|
||||
);
|
||||
@@ -10,7 +10,6 @@ import { Abs } from './data/Abs';
|
||||
import { Add } from './data/Add';
|
||||
import { And } from './data/And';
|
||||
import { AxisInput } from './data/AxisInput';
|
||||
import { BooleanLiteral } from './data/Boolean';
|
||||
import { Ceil } from './data/Ceil';
|
||||
import { Clamp } from './data/Clamp';
|
||||
import { Compare } from './data/Compare';
|
||||
@@ -27,6 +26,7 @@ import { IsTouching } from './data/IsTouching';
|
||||
import { Join } from './data/Join';
|
||||
import { KeyIsDown } from './data/KeyIsDown';
|
||||
import { Length } from './data/Length';
|
||||
import { BooleanLiteral, ListLiteral, NumberLiteral, StringLiteral, TextureLiteral } from './data/literals';
|
||||
import { Max } from './data/Max';
|
||||
import { Min } from './data/Min';
|
||||
import { Modulo } from './data/Modulo';
|
||||
@@ -36,7 +36,6 @@ import { Multiply } from './data/Multiply';
|
||||
import { NameOf } from './data/NameOf';
|
||||
import { Normalize } from './data/Normalize';
|
||||
import { Not } from './data/Not';
|
||||
import { NumberLiteral } from './data/Number';
|
||||
import { ObjectByName } from './data/ObjectByName';
|
||||
import { Or } from './data/Or';
|
||||
import { Owner } from './data/Owner';
|
||||
@@ -47,10 +46,8 @@ import { Random } from './data/Random';
|
||||
import { Round } from './data/Round';
|
||||
import { Self } from './data/Self';
|
||||
import { Sqrt } from './data/Sqrt';
|
||||
import { StringLiteral } from './data/String';
|
||||
import { Substring } from './data/Substring';
|
||||
import { Subtract } from './data/Subtract';
|
||||
import { TextureLiteral } from './data/Texture';
|
||||
import { Time } from './data/Time';
|
||||
import { ToNumber } from './data/ToNumber';
|
||||
import { ToText } from './data/ToText';
|
||||
@@ -60,7 +57,6 @@ import { ExpandStruct } from './data/ExpandStruct';
|
||||
import { Fill } from './data/Fill';
|
||||
import { IndexOf } from './data/IndexOf';
|
||||
import { ItemAt } from './data/ItemAt';
|
||||
import { ListLiteral } from './data/ListLiteral';
|
||||
import { SetItem } from './data/SetItem';
|
||||
import { SubList } from './data/SubList';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { type DeclaredPortKind, isKindWith, KIND, plainKindOf } from '../properties/kinds';
|
||||
import { itemKindOf, structItemFields } from '../properties/listItems';
|
||||
import type { PropertyDef, StructFieldDef } from '../properties/PropertySchema';
|
||||
import { DECLARED_PORT_KINDS, type DeclaredPort, type DeclaredPortKind, type GraphNode } from './Graph';
|
||||
import type { DeclaredPort, GraphNode } from './Graph';
|
||||
|
||||
/** The port a variable or property travels on. An enum, a key or an asset is text. */
|
||||
/** The port a variable or property travels on. A kind no port declares (an enum, a key) is what it travels as. */
|
||||
export function portKindOf(def: PropertyDef): DeclaredPortKind {
|
||||
return (DECLARED_PORT_KINDS as readonly string[]).includes(def.kind)
|
||||
? (def.kind as DeclaredPortKind)
|
||||
: 'text';
|
||||
if (isKindWith('port', def.kind)) return def.kind;
|
||||
const plain = plainKindOf(KIND[def.kind].travels);
|
||||
return plain !== undefined && isKindWith('port', plain) ? plain : 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TaggedKind, ValueOf } from '../properties/kinds';
|
||||
|
||||
/** Depth is capped: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged colour or texture. */
|
||||
export type FieldValue = number | string | boolean | ListValue | ColorItem | TextureItem;
|
||||
/** What a struct field may hold. A field is never itself a struct – except a tagged item. */
|
||||
export type FieldValue = number | string | boolean | ListValue | TaggedItem;
|
||||
|
||||
/** Several named values travelling as one, so a bundle needs one port instead of one per part. */
|
||||
export type StructValue = { readonly [field: string]: FieldValue };
|
||||
@@ -15,42 +17,38 @@ export type PropertyBag = Record<string, PropertyValue>;
|
||||
export const isList = (value: unknown): value is ListValue => Array.isArray(value);
|
||||
|
||||
/**
|
||||
* Inside a list a color and a texture have to say so: a list declares nothing about its items, and
|
||||
* there 0xRRGGBB is a plain number and an asset id a plain text. A field name cannot start with `$`
|
||||
* (`isValidFieldName`), so a tag can never collide with a struct item of one field.
|
||||
* A value of a kind JSON cannot show, under its kind's tag: `{ "$color": 16711680 }`,
|
||||
* `{ "$texture": "walk-up" }`. Only a list needs it, where nothing is declared. A field name cannot
|
||||
* start with `$` (`isValidFieldName`), so a tag never collides with a struct item of one field.
|
||||
*/
|
||||
export const COLOR_TAG = '$color';
|
||||
export const TEXTURE_TAG = '$texture';
|
||||
export type TaggedItem = {
|
||||
[K in TaggedKind]: { readonly [P in `$${K}`]: ValueOf<K> } & TaggedFields;
|
||||
}[TaggedKind];
|
||||
|
||||
/** Indexable like any struct, so the one place that reads fields by name needs no special case. */
|
||||
export interface ColorItem {
|
||||
readonly $color: number;
|
||||
interface TaggedFields {
|
||||
readonly [field: string]: FieldValue;
|
||||
}
|
||||
|
||||
/** A picture a sprite can wear: a texture, one of its frames, or an animation – all one id. */
|
||||
export interface TextureItem {
|
||||
readonly $texture: string;
|
||||
readonly [field: string]: FieldValue;
|
||||
const TAG = '$';
|
||||
|
||||
/** The kind `value` wears as a tag, if it is a tagged item. */
|
||||
export function tagOf(value: unknown): string | undefined {
|
||||
if (!isStruct(value)) return undefined;
|
||||
const keys = Object.keys(value);
|
||||
const key = keys.length === 1 ? keys[0] : undefined;
|
||||
return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined;
|
||||
}
|
||||
|
||||
const isTagged = (value: unknown, tag: string): boolean =>
|
||||
isStruct(value) && tag in value && Object.keys(value).length === 1;
|
||||
export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
|
||||
return { [TAG + kind]: value } as TaggedItem;
|
||||
}
|
||||
|
||||
export const isColorItem = (value: unknown): value is ColorItem => isTagged(value, COLOR_TAG);
|
||||
|
||||
export const isTextureItem = (value: unknown): value is TextureItem =>
|
||||
isTagged(value, TEXTURE_TAG) && typeof (value as TextureItem).$texture === 'string';
|
||||
|
||||
export const colorItem = (color: number): ColorItem => ({ $color: color });
|
||||
|
||||
export const textureItem = (id: string): TextureItem => ({ $texture: id });
|
||||
|
||||
/** The number behind a tagged color. */
|
||||
export const colorItemValue = (item: ColorItem): number => colorOf(item.$color, 0);
|
||||
|
||||
/** The id behind a tagged texture. */
|
||||
export const textureItemValue = (item: TextureItem): string => item.$texture;
|
||||
/** A tagged item as what it wraps; anything else as itself. */
|
||||
export function untag(value: PropertyValue | undefined): PropertyValue | undefined {
|
||||
const tag = tagOf(value);
|
||||
return tag === undefined ? value : (value as StructValue)[TAG + tag];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-level only: a live entity handle is an object too, and the graph tells the two apart by
|
||||
@@ -67,8 +65,7 @@ export function textOf(value: PropertyValue | undefined): string {
|
||||
if (value === undefined) return '';
|
||||
if (isList(value)) return value.map((item) => textOf(item)).join(', ');
|
||||
// A tagged value reads as what it wraps, the same as the matching property does.
|
||||
if (isColorItem(value)) return String(colorItemValue(value));
|
||||
if (isTextureItem(value)) return textureItemValue(value);
|
||||
if (tagOf(value) !== undefined) return textOf(untag(value));
|
||||
if (isStruct(value))
|
||||
return Object.entries(value)
|
||||
.map(([field, item]) => `${field}: ${textOf(item)}`)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* `Project.ts` so that module stays free of the property schema and of container instancing:
|
||||
* both of those already import it, and answering this question needs both.
|
||||
*/
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import type { AnimationAsset, Asset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
@@ -48,7 +49,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
|
||||
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
|
||||
for (const asset of project.assets) {
|
||||
const keys = schemaForAsset(asset)
|
||||
.filter((def) => def.kind === 'asset' || def.kind === 'texture')
|
||||
.filter((def) => isKindWith('reference', def.kind))
|
||||
.map((def) => def.key);
|
||||
refKeys.set(asset.id, keys);
|
||||
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
|
||||
|
||||
@@ -9,44 +9,40 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
booleanOf,
|
||||
colorOf,
|
||||
} from '../model/Properties';
|
||||
import { coerceField, flatListOf, listItemOf } from './listItems';
|
||||
import {
|
||||
coerceAs,
|
||||
coerceField,
|
||||
emptyOf,
|
||||
type FieldKind,
|
||||
type FlatKind,
|
||||
type ValueOf,
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
|
||||
/**
|
||||
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
|
||||
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
|
||||
* produces fractions, and the field rounds the value away rather than hiding it. Without
|
||||
* `assetTypes` an asset picker offers every placeable asset.
|
||||
*/
|
||||
type Extras<K extends FlatKind> = K extends 'number'
|
||||
? { min?: number; max?: number; step?: number; decimals?: number }
|
||||
: K extends 'enum'
|
||||
? { options: { value: string; label: string }[] }
|
||||
: K extends 'asset'
|
||||
? { assetTypes?: readonly AssetType[] }
|
||||
: unknown;
|
||||
|
||||
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
|
||||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
|
||||
}[K];
|
||||
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
export type PropertyDef =
|
||||
/**
|
||||
* `decimals` belongs only on properties that are integral by meaning (counts, sizes in whole
|
||||
* pixels), not on geometry: dragging produces fractions, and the field rounds the value away
|
||||
* rather than hiding it.
|
||||
*/
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: 'number';
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
decimals?: number;
|
||||
}
|
||||
| { key: string; label: string; kind: 'text'; default: string }
|
||||
| { key: string; label: string; kind: 'color'; default: number }
|
||||
| { key: string; label: string; kind: 'boolean'; default: boolean }
|
||||
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
| { key: string; label: string; kind: 'key'; default: string }
|
||||
/**
|
||||
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
|
||||
* Ids are not validated on load, so a reference can dangle and readers must cope.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
|
||||
/**
|
||||
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
|
||||
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'texture'; default: string }
|
||||
| FlatDef
|
||||
/**
|
||||
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
|
||||
* and the graph types a list as a list, so one `list` port fits them all. There is no `object`
|
||||
@@ -60,18 +56,6 @@ export type PropertyDef =
|
||||
*/
|
||||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||||
|
||||
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
|
||||
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color', 'texture'] as const;
|
||||
export type FieldKind = (typeof FIELD_KINDS)[number];
|
||||
|
||||
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
|
||||
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
|
||||
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
|
||||
|
||||
/** 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;
|
||||
@@ -89,8 +73,17 @@ export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'color', 'texture', 'list', 'struct'] as const;
|
||||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||
/**
|
||||
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
|
||||
* cast is the table's promise that `coerce` yields the kind.
|
||||
*/
|
||||
export function flatDef<K extends FieldKind>(
|
||||
key: string,
|
||||
kind: K,
|
||||
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
|
||||
): FlatDef<K> {
|
||||
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
|
||||
@@ -238,35 +231,20 @@ export function variableDef(
|
||||
fields?: readonly StructFieldDef[],
|
||||
): PropertyDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
|
||||
case 'boolean':
|
||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||
case 'text':
|
||||
case 'texture':
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
|
||||
case 'color':
|
||||
return { key, label: key, kind, default: colorOf(defaultValue ?? 0, 0) };
|
||||
case 'list':
|
||||
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
|
||||
case 'struct': {
|
||||
const declared = fields ?? [];
|
||||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||||
}
|
||||
default:
|
||||
return flatDef(key, kind, { value: defaultValue });
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
text: '',
|
||||
boolean: false,
|
||||
color: 0,
|
||||
texture: '',
|
||||
};
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
|
||||
return field.kind === 'list' ? [] : emptyOf(field.kind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,21 +263,11 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||
switch (def.kind) {
|
||||
case 'number': {
|
||||
let n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) n = def.default;
|
||||
let n = coerceAs(value, def.kind, def.default);
|
||||
if (def.min !== undefined) n = Math.max(def.min, n);
|
||||
if (def.max !== undefined) n = Math.min(def.max, n);
|
||||
return n;
|
||||
}
|
||||
case 'color':
|
||||
return colorOf(value, def.default);
|
||||
case 'boolean':
|
||||
return booleanOf(value);
|
||||
case 'text':
|
||||
case 'key':
|
||||
case 'asset':
|
||||
case 'texture':
|
||||
return textOf(value);
|
||||
case 'enum': {
|
||||
const s = textOf(value);
|
||||
return def.options.some((o) => o.value === s) ? s : def.default;
|
||||
@@ -309,6 +277,8 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
return isList(value) ? value.map(listItemOf) : def.default;
|
||||
case 'struct':
|
||||
return structOf(def.fields, value);
|
||||
default:
|
||||
return coerceAs(value, def.kind, def.default);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Every kind a value can be, in one table: what it is called, how it is marked, what it travels as
|
||||
* on a data edge, its empty value, how any value is read as it, and where it may be picked. A new
|
||||
* kind is a row here – the kind lists, the menus, the tags and the coercions follow from it.
|
||||
*
|
||||
* A `tagged` kind is one JSON cannot show: a color is a number, a texture id a text. In a list,
|
||||
* where nothing is declared, such an item wears its tag (`tagOf`), and a port of that kind carries
|
||||
* the field that edits it. A `reference` kind names something in the project.
|
||||
*/
|
||||
import type { PortType } from '../graph/Graph';
|
||||
import { booleanOf, colorOf, type PropertyValue, textOf } from '../model/Properties';
|
||||
|
||||
interface KindRow {
|
||||
readonly kind: string;
|
||||
readonly label: string;
|
||||
readonly mark?: string;
|
||||
readonly travels: PortType;
|
||||
readonly empty?: number | string | boolean;
|
||||
readonly coerce?: (value: unknown, fallback: never) => number | string | boolean;
|
||||
readonly tagged?: true;
|
||||
readonly reference?: true;
|
||||
readonly variable?: true;
|
||||
readonly field?: true;
|
||||
readonly port?: true;
|
||||
}
|
||||
|
||||
const numberOf = (value: unknown, fallback: number): number => {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const text = (value: unknown): string => textOf(value as PropertyValue | undefined);
|
||||
const bool = (value: unknown): boolean => booleanOf(value);
|
||||
const color = (value: unknown, fallback: number): number => colorOf(value as PropertyValue, fallback);
|
||||
|
||||
export const KINDS = [
|
||||
{
|
||||
kind: 'number',
|
||||
label: 'Number',
|
||||
mark: '#',
|
||||
travels: 'number',
|
||||
empty: 0,
|
||||
coerce: numberOf,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{
|
||||
kind: 'text',
|
||||
label: 'Text',
|
||||
mark: 'Aa',
|
||||
travels: 'string',
|
||||
empty: '',
|
||||
coerce: text,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{
|
||||
kind: 'boolean',
|
||||
label: 'Boolean',
|
||||
mark: '✓',
|
||||
travels: 'boolean',
|
||||
empty: false,
|
||||
coerce: bool,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{
|
||||
kind: 'color',
|
||||
label: 'Color',
|
||||
mark: '◧',
|
||||
travels: 'number',
|
||||
empty: 0,
|
||||
coerce: color,
|
||||
tagged: true,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
/** A texture, one of its frames or an animation – all one id, which is why it is not an `asset`. */
|
||||
{
|
||||
kind: 'texture',
|
||||
label: 'Texture',
|
||||
mark: '▦',
|
||||
travels: 'string',
|
||||
empty: '',
|
||||
coerce: text,
|
||||
tagged: true,
|
||||
reference: true,
|
||||
variable: true,
|
||||
field: true,
|
||||
port: true,
|
||||
},
|
||||
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
|
||||
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
|
||||
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
|
||||
// Schema-only kinds: a setting has them, nothing a user declares does.
|
||||
{ kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text },
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
{ kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text },
|
||||
/** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */
|
||||
{ kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true },
|
||||
] as const satisfies readonly KindRow[];
|
||||
|
||||
type Row = (typeof KINDS)[number];
|
||||
export type Kind = Row['kind'];
|
||||
type RowOf<K extends Kind> = Extract<Row, { kind: K }>;
|
||||
type With<Role extends string> = Extract<Row, Record<Role, true>>['kind'];
|
||||
|
||||
export type VariableKind = With<'variable'>;
|
||||
export type FieldKind = With<'field'>;
|
||||
export type DeclaredPortKind = With<'port'>;
|
||||
export type TaggedKind = With<'tagged'>;
|
||||
export type ReferenceKind = With<'reference'>;
|
||||
/** The kinds whose value is one primitive – the ones with an empty value. */
|
||||
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
|
||||
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
|
||||
export type ListItemKind = FieldKind | 'struct';
|
||||
/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */
|
||||
export type StructFieldKind = FieldKind | 'list';
|
||||
|
||||
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
|
||||
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
|
||||
|
||||
export const KIND = Object.fromEntries(KINDS.map((row) => [row.kind, row])) as { [K in Kind]: RowOf<K> };
|
||||
|
||||
const rowOf = (kind: string): Row | undefined => (KIND as Partial<Record<string, Row>>)[kind];
|
||||
|
||||
export function isKindWith<Role extends string>(role: Role, kind: string): kind is With<Role> {
|
||||
return (rowOf(kind) as Partial<Record<string, unknown>> | undefined)?.[role] === true;
|
||||
}
|
||||
|
||||
const kindsWith = <Role extends string>(role: Role): readonly With<Role>[] =>
|
||||
KINDS.map((row) => row.kind).filter((kind): kind is With<Role> => isKindWith(role, kind));
|
||||
|
||||
export const VARIABLE_KINDS = kindsWith('variable');
|
||||
export const FIELD_KINDS = kindsWith('field');
|
||||
export const DECLARED_PORT_KINDS = kindsWith('port');
|
||||
export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct'];
|
||||
export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list'];
|
||||
|
||||
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
return rowOf(kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
export function kindMark(kind: string): string {
|
||||
const row = rowOf(kind);
|
||||
return row && 'mark' in row ? row.mark : '?';
|
||||
}
|
||||
|
||||
/** A flat row as its kind's value type – what the table promises and a generic `K` cannot see. */
|
||||
const flatRow = <K extends FlatKind>(kind: K) =>
|
||||
KIND[kind] as unknown as {
|
||||
empty: ValueOf<K>;
|
||||
coerce: (value: unknown, fallback: ValueOf<K>) => ValueOf<K>;
|
||||
};
|
||||
|
||||
export function emptyOf<K extends FlatKind>(kind: K): ValueOf<K> {
|
||||
return flatRow(kind).empty;
|
||||
}
|
||||
|
||||
/** `value` read as `kind`, `fallback` where it cannot be. */
|
||||
export function coerceAs<K extends FlatKind>(value: unknown, kind: K, fallback: ValueOf<K>): ValueOf<K> {
|
||||
return flatRow(kind).coerce(value, fallback);
|
||||
}
|
||||
|
||||
export function coerceField<K extends FlatKind>(value: unknown, kind: K): ValueOf<K> {
|
||||
return coerceAs(value, kind, emptyOf(kind));
|
||||
}
|
||||
|
||||
/** The kind a bare value travelling as `type` reads as: the untagged kind of that port type. */
|
||||
export function plainKindOf(type: string): VariableKind | undefined {
|
||||
return VARIABLE_KINDS.find((kind) => !isTaggedKind(kind) && KIND[kind].travels === type);
|
||||
}
|
||||
@@ -1,45 +1,44 @@
|
||||
/**
|
||||
* A list declares nothing about what it holds: every item carries its own kind, read back off the
|
||||
* value. JSON already says what a number, a text, a boolean and a struct are, so only a color and a
|
||||
* texture need a tag (`COLOR_TAG`, `TEXTURE_TAG`) – which keeps every list ever stored readable.
|
||||
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
|
||||
* (`tagOf`), which keeps every list ever stored readable.
|
||||
*
|
||||
* Depth is still capped the way the declarations used to cap it: a struct item holds flat items,
|
||||
* and so does a list inside a struct field.
|
||||
* Depth is capped the way the declarations used to cap it: a struct item holds flat items, and so
|
||||
* does a list inside a struct field.
|
||||
*/
|
||||
import {
|
||||
booleanOf,
|
||||
type ColorItem,
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
colorOf,
|
||||
type FieldValue,
|
||||
isColorItem,
|
||||
isList,
|
||||
isStruct,
|
||||
isTextureItem,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
type TextureItem,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
type TaggedItem,
|
||||
tagged,
|
||||
tagOf,
|
||||
untag,
|
||||
} from '../model/Properties';
|
||||
import type { FieldKind, ListItemKind } from './PropertySchema';
|
||||
import {
|
||||
coerceField,
|
||||
type FieldKind,
|
||||
isKindWith,
|
||||
isTaggedKind,
|
||||
type ListItemKind,
|
||||
plainKindOf,
|
||||
} from './kinds';
|
||||
|
||||
/** What a list holds. Identical to `ListValue[number]`, named for the places that mean one item. */
|
||||
export type ListItem = number | string | boolean | StructValue;
|
||||
|
||||
/** What a flat position holds: no struct except a tagged color or texture, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | ColorItem | TextureItem;
|
||||
/** What a flat position holds: no struct except a tagged item, so the depth stays capped. */
|
||||
export type FlatItem = number | string | boolean | TaggedItem;
|
||||
|
||||
/** The kind an item is, taken from the item itself – there is nowhere else it could come from. */
|
||||
export function itemKindOf(item: PropertyValue | undefined): ListItemKind {
|
||||
if (isColorItem(item)) return 'color';
|
||||
if (isTextureItem(item)) return 'texture';
|
||||
const tag = tagOf(item);
|
||||
if (tag !== undefined && isTaggedKind(tag)) return tag;
|
||||
if (isStruct(item) || isList(item)) return 'struct';
|
||||
if (typeof item === 'string') return 'text';
|
||||
if (typeof item === 'boolean') return 'boolean';
|
||||
return 'number';
|
||||
const plain = plainKindOf(typeof item);
|
||||
return plain !== undefined && isKindWith('field', plain) ? plain : 'number';
|
||||
}
|
||||
|
||||
/** Flat positions hold no struct, so one reads as its text. */
|
||||
@@ -48,26 +47,9 @@ export function fieldKindOf(value: PropertyValue | undefined): FieldKind {
|
||||
return kind === 'struct' ? 'text' : kind;
|
||||
}
|
||||
|
||||
/** What the widgets and the graph see: a tag is what it wraps, everything else is itself. */
|
||||
export function itemValue(item: PropertyValue | undefined): PropertyValue | undefined {
|
||||
if (isColorItem(item)) return colorItemValue(item);
|
||||
if (isTextureItem(item)) return textureItemValue(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** `value` as an item of `kind`, which is how a row changes what it holds. */
|
||||
export function makeItem(kind: ListItemKind, value: PropertyValue | undefined): ListItem {
|
||||
switch (kind) {
|
||||
case 'color':
|
||||
// Through `itemValue`: a tagged color handed to `colorOf` would be read as hex text.
|
||||
return colorItem(colorOf(itemValue(value) ?? 0, 0));
|
||||
case 'texture':
|
||||
return textureItem(textOf(itemValue(value)));
|
||||
case 'struct':
|
||||
return structItemOf(value);
|
||||
default:
|
||||
return coerceField(itemValue(value), kind);
|
||||
}
|
||||
return kind === 'struct' ? structItemOf(value) : makeFlatItem(kind, value);
|
||||
}
|
||||
|
||||
/** `value` as an item of the kind the slot already holds, so writing a color keeps it a color. */
|
||||
@@ -77,9 +59,8 @@ export function itemLike(current: PropertyValue | undefined, value: PropertyValu
|
||||
|
||||
/** `value` as a flat item of `kind` – a struct item's field, where a struct itself cannot go. */
|
||||
export function makeFlatItem(kind: FieldKind, value: PropertyValue | undefined): FlatItem {
|
||||
if (kind === 'color') return colorItem(colorOf(itemValue(value) ?? 0, 0));
|
||||
if (kind === 'texture') return textureItem(textOf(itemValue(value)));
|
||||
return coerceField(itemValue(value), kind);
|
||||
if (isTaggedKind(kind)) return tagged(kind, coerceField(untag(value), kind));
|
||||
return coerceField(untag(value), kind);
|
||||
}
|
||||
|
||||
/** A flat slot (a struct item's field, an item of a list in a struct field) keeps its kind too. */
|
||||
@@ -93,19 +74,17 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* A flat position holds a primitive or a tagged color or texture. Anything deeper is dropped rather
|
||||
* than flattened – the same bound the declarations used to draw, and the one the store writes.
|
||||
* A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than
|
||||
* flattened – the same bound the declarations used to draw, and the one the store writes.
|
||||
*/
|
||||
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
if (isTextureItem(value)) return textureItem(textureItemValue(value));
|
||||
if (isList(value) || isStruct(value)) return undefined;
|
||||
return coerceField(value, fieldKindOf(value));
|
||||
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
|
||||
return makeFlatItem(fieldKindOf(value), value);
|
||||
}
|
||||
|
||||
/** A struct item keeps the fields it carries, each as the kind its own value says. */
|
||||
export function structItemOf(value: PropertyValue | undefined): StructValue {
|
||||
if (!isStruct(value) || isColorItem(value) || isTextureItem(value)) return {};
|
||||
if (!isStruct(value) || tagOf(value) !== undefined) return {};
|
||||
const fields: Record<string, FieldValue> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const flat = flatItemOf(item);
|
||||
@@ -122,15 +101,6 @@ export function flatListOf(value: PropertyValue | undefined): readonly FlatItem[
|
||||
|
||||
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
|
||||
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: FieldKind }[] {
|
||||
if (!isStruct(item) || isColorItem(item) || isTextureItem(item)) return [];
|
||||
if (!isStruct(item) || tagOf(item) !== undefined) return [];
|
||||
return Object.entries(item).map(([key, value]) => ({ key, kind: fieldKindOf(value) }));
|
||||
}
|
||||
|
||||
/** A primitive as one of the flat kinds. The spelling of a text counts, see `booleanOf`. */
|
||||
export function coerceField(value: PropertyValue | undefined, kind: FieldKind): number | string | boolean {
|
||||
if (kind === 'text' || kind === 'texture') return textOf(value);
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (kind === 'color') return colorOf(value ?? 0, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
+13
-15
@@ -1,7 +1,6 @@
|
||||
// Importing the graph package index is what registers the built-in nodes.
|
||||
import {
|
||||
createEmptyGraph,
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type DeclaredPortKind,
|
||||
type Graph,
|
||||
@@ -15,36 +14,35 @@ import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
import {
|
||||
colorItem,
|
||||
colorItemValue,
|
||||
type FieldValue,
|
||||
isTextureItem,
|
||||
textureItem,
|
||||
textureItemValue,
|
||||
isColorItem,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
tagOf,
|
||||
} from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import type { FlatItem } from '../properties/listItems';
|
||||
import {
|
||||
DECLARED_PORT_KINDS,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
isTaggedKind,
|
||||
VARIABLE_KINDS,
|
||||
type VariableKind,
|
||||
} from '../properties/kinds';
|
||||
import { type FlatItem, makeFlatItem } from '../properties/listItems';
|
||||
import {
|
||||
coerceValue,
|
||||
COMMON_PROPS,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
type PropertyDef,
|
||||
type PropertySchema,
|
||||
schemaFor,
|
||||
type StructFieldDef,
|
||||
VARIABLE_KINDS,
|
||||
variableDef,
|
||||
type VariableKind,
|
||||
} from '../properties/PropertySchema';
|
||||
|
||||
/**
|
||||
@@ -356,11 +354,11 @@ function asStoredValue(value: unknown): PropertyValue | undefined {
|
||||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||||
|
||||
/** A primitive, or the objects a flat position may hold: a tagged color or texture. */
|
||||
/** A primitive, or the one object a flat position may hold: a tagged item. */
|
||||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||||
if (isPrimitive(value)) return value;
|
||||
if (isColorItem(value)) return colorItem(colorItemValue(value));
|
||||
return isTextureItem(value) ? textureItem(textureItemValue(value)) : undefined;
|
||||
const tag = tagOf(value);
|
||||
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
|
||||
};
|
||||
|
||||
/** A list item: a flat one, or a struct of flat ones. */
|
||||
|
||||
@@ -17,11 +17,9 @@ import {
|
||||
} from '../../core/graph';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
||||
import { assetRefLabel } from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { textureRefLabel } from '../../core/properties/textureRef';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, toHex, truncate, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
|
||||
import { collapsedNodeWidth } from './folding';
|
||||
|
||||
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
|
||||
@@ -431,11 +429,9 @@ function formatParam(param: PropertyDef, value: PropertyValue | undefined, proje
|
||||
/** A value in the words of the field that edits it: a key's name, a color's hex, a texture's name. */
|
||||
function formatField(def: PropertyDef, value: Value, project: Project | null): string {
|
||||
if (def.kind === 'key') return keyLabel(asString(value));
|
||||
if (def.kind === 'asset') return assetRefLabel(project, asString(value));
|
||||
if (def.kind === 'texture') return textureRefLabel(project, asString(value));
|
||||
if (def.kind === 'color') return toHex(asNumber(value));
|
||||
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
|
||||
return formatValue(value);
|
||||
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
||||
}
|
||||
|
||||
function formatLive(output: PortDef, value: Value, project: Project | null): string {
|
||||
|
||||
@@ -15,20 +15,15 @@ import {
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import {
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
VARIABLE_KINDS,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { type StructFieldKind, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
import { type PropertyDef, schemaForAsset, type StructFieldDef } from '../../core/properties/PropertySchema';
|
||||
import { markedAssetSchema } from '../../core/properties/deviation';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Dropdown, HStack, Label, Menu, TextField, Theme } from '../../ui';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
|
||||
import { buildVariable, kindMenu, type VariableEditing } from './variableRows';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { assetValue } from './valueSpecs';
|
||||
|
||||
@@ -137,14 +132,11 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
header.add(new Label('VARIABLES', { style: 'title' }), 1);
|
||||
const add = new Dropdown('+ Variable', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = VARIABLE_KINDS.map((kind) => ({
|
||||
label: KIND_LABELS[kind],
|
||||
onSelect: () => {
|
||||
const current = findAsset(host.state.project, asset.id);
|
||||
if (current?.type === 'behavior')
|
||||
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||
},
|
||||
}));
|
||||
const items = kindMenu(VARIABLE_KINDS, (kind) => {
|
||||
const current = findAsset(host.state.project, asset.id);
|
||||
if (current?.type === 'behavior')
|
||||
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||
});
|
||||
new Menu(items).openBelow(add);
|
||||
};
|
||||
header.add(add);
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
assetRefLabel,
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
||||
import {
|
||||
emptyFieldValue,
|
||||
type FieldKind,
|
||||
flatDef,
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
@@ -202,29 +203,70 @@ export function removeButton(onClick: () => void): Button {
|
||||
|
||||
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
|
||||
|
||||
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
|
||||
export function primitiveDef(key: string, kind: FieldKind, 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 };
|
||||
case 'texture':
|
||||
return { key, label, kind: 'texture', default: '' };
|
||||
}
|
||||
}
|
||||
|
||||
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: [] }
|
||||
: primitiveDef(field.key, field.kind, label);
|
||||
: flatDef(field.key, field.kind, { label });
|
||||
}
|
||||
|
||||
/** What a reference kind names in the project: how an id reads, and what its picker may offer. */
|
||||
interface Reference {
|
||||
none: string;
|
||||
label(project: Project | null, id: string): string;
|
||||
choices(project: Project, def: PropertyDef): { id: string; label: string }[];
|
||||
}
|
||||
|
||||
const REFERENCES: Record<ReferenceKind, Reference> = {
|
||||
asset: {
|
||||
none: NO_ASSET_LABEL,
|
||||
label: assetRefLabel,
|
||||
choices: (project, def) =>
|
||||
assetRefChoices(project, def.kind === 'asset' ? def.assetTypes : undefined).map((asset) => ({
|
||||
id: asset.id,
|
||||
label: assetChoiceLabel(asset),
|
||||
})),
|
||||
},
|
||||
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices },
|
||||
};
|
||||
|
||||
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
|
||||
export function referenceLabel(def: PropertyDef, project: Project | null, id: string): string | undefined {
|
||||
return isKindWith('reference', def.kind) ? REFERENCES[def.kind].label(project, id) : undefined;
|
||||
}
|
||||
|
||||
function referenceField(
|
||||
ctx: FieldContext,
|
||||
def: PropertyDef,
|
||||
ref: Reference,
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
): Field {
|
||||
// The current id is kept here rather than read back off the label: the label is a name, two
|
||||
// assets may share one (a frame's label repeats its sheet's), and a dangling reference has none.
|
||||
let current = textOf(value);
|
||||
const button = new Dropdown(ref.label(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', ref.none), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while the
|
||||
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
|
||||
...ref.choices(ctx.project(), def).map((choice) => ({
|
||||
label: mark(choice.id, choice.label),
|
||||
onSelect: () => handlers.onChange(choice.id),
|
||||
})),
|
||||
];
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
widget: button,
|
||||
grow: true,
|
||||
setValue: (v) => {
|
||||
current = textOf(v);
|
||||
button.setText(ref.label(ctx.project(), current));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||||
@@ -308,60 +350,9 @@ export function createField(
|
||||
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
|
||||
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
|
||||
}
|
||||
case 'asset': {
|
||||
// The current id is kept here rather than read back off the label: the label is a name,
|
||||
// two assets may share one, and a dangling reference has no name at all.
|
||||
let current = textOf(value);
|
||||
const button = new Dropdown(assetRefLabel(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while
|
||||
// the inspector stands.
|
||||
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
|
||||
label: mark(asset.id, assetChoiceLabel(asset)),
|
||||
onSelect: () => handlers.onChange(asset.id),
|
||||
})),
|
||||
];
|
||||
// Searchable: the list grows with the project, like the parent picker.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
widget: button,
|
||||
grow: true,
|
||||
setValue: (v) => {
|
||||
current = textOf(v);
|
||||
button.setText(assetRefLabel(ctx.project(), current));
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'texture': {
|
||||
// Kept for the same reason as in the asset case, and more urgently: a frame's label
|
||||
// repeats its sheet's name, so several entries can read identically.
|
||||
let current = textOf(value);
|
||||
const button = new Dropdown(textureRefLabel(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') },
|
||||
...textureRefChoices(ctx.project()).map((choice) => ({
|
||||
label: mark(choice.id, choice.label),
|
||||
onSelect: () => handlers.onChange(choice.id),
|
||||
})),
|
||||
];
|
||||
// Searchable: one sliced sheet alone contributes an entry per frame.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
widget: button,
|
||||
grow: true,
|
||||
setValue: (v) => {
|
||||
current = textOf(v);
|
||||
button.setText(textureRefLabel(ctx.project(), current));
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'asset':
|
||||
case 'texture':
|
||||
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '../../core/commands/portCommands';
|
||||
import {
|
||||
BEHAVIOR_START,
|
||||
DECLARED_PORT_KINDS,
|
||||
type DeclaredPort,
|
||||
type GraphNode,
|
||||
isPortOf,
|
||||
@@ -26,7 +25,8 @@ import {
|
||||
} from '../../core/graph';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS } from '../../core/properties/PropertySchema';
|
||||
import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||||
import { type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from '../../ui';
|
||||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
|
||||
import { kindMenu, markLabel } from './variableRows';
|
||||
|
||||
/**
|
||||
* The ports of a Variables node: each one *is* a variable of the behavior, so it is picked from that
|
||||
@@ -121,13 +121,11 @@ function buildPickedPortSection(
|
||||
onSelect: () => onPick(portOf(def)),
|
||||
}));
|
||||
const typed = (): MenuItem[] =>
|
||||
VARIABLE_KINDS.map((kind) => ({
|
||||
label: KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||||
),
|
||||
}));
|
||||
kindMenu(VARIABLE_KINDS, (kind) =>
|
||||
host.state.execute(
|
||||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||||
),
|
||||
);
|
||||
const searchable = noun === 'Property';
|
||||
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
@@ -189,11 +187,9 @@ export function buildPortSection(
|
||||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
const items = DECLARED_PORT_KINDS.map((kind) => ({
|
||||
label: KIND_LABELS[kind],
|
||||
onSelect: () =>
|
||||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||||
}));
|
||||
const items = kindMenu(DECLARED_PORT_KINDS, (kind) =>
|
||||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||||
);
|
||||
new Menu(items).openBelow(add);
|
||||
};
|
||||
header.add(add);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { DeclaredPortKind } from '../../core/graph';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import {
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
STRUCT_FIELD_KINDS,
|
||||
type StructDef,
|
||||
type StructFieldDef,
|
||||
type StructFieldKind,
|
||||
type VariableKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds';
|
||||
import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
@@ -38,34 +30,9 @@ import {
|
||||
} from './fieldFactory';
|
||||
import { addNote, type InspectorHost } from './host';
|
||||
|
||||
type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
|
||||
|
||||
/** One glyph and one name per kind, wherever a kind is shown: variables, list items, fields, ports. */
|
||||
const KIND_MARKS: Record<ShownKind, string> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
color: '◧',
|
||||
texture: '▦',
|
||||
list: '≡',
|
||||
struct: '⊞',
|
||||
object: '◇',
|
||||
};
|
||||
|
||||
export const KIND_LABELS: Record<ShownKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
color: 'Color',
|
||||
texture: 'Texture',
|
||||
list: 'List',
|
||||
struct: 'Struct',
|
||||
object: 'Object',
|
||||
};
|
||||
|
||||
/** Variables are `PropertyDef`s, so the kind is typed wide; only the shown kinds ever reach it. */
|
||||
export function kindMark(kind: string): string {
|
||||
return (KIND_MARKS as Partial<Record<string, string>>)[kind] ?? '?';
|
||||
/** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */
|
||||
export function kindMenu<K extends string>(kinds: readonly K[], pick: (kind: K) => void): MenuItem[] {
|
||||
return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) }));
|
||||
}
|
||||
|
||||
/** Changing a declaration – offered only on the asset that declares it. */
|
||||
@@ -149,7 +116,7 @@ function structFieldRows(
|
||||
|
||||
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
|
||||
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
|
||||
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
|
||||
return gridRow(1, markLabel(kindMark(field.kind)), name);
|
||||
}
|
||||
|
||||
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
|
||||
@@ -198,10 +165,6 @@ export function markPicker(glyph: string, items: MenuItem[]): Button {
|
||||
|
||||
function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown {
|
||||
const add = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
add.onClick = () => {
|
||||
new Menu(
|
||||
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
|
||||
).openBelow(add);
|
||||
};
|
||||
add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add);
|
||||
return add;
|
||||
}
|
||||
@@ -18,33 +18,39 @@ import {
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { type FieldValue, type ListValue, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import {
|
||||
type FieldValue,
|
||||
type ListValue,
|
||||
type PropertyValue,
|
||||
textOf,
|
||||
untag,
|
||||
} from '../../core/model/Properties';
|
||||
import { nextFieldName } from '../../core/commands/naming';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
kindMark,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
} from '../../core/properties/kinds';
|
||||
import {
|
||||
type ListItem,
|
||||
itemKindOf,
|
||||
itemValue,
|
||||
makeFlatItem,
|
||||
makeItem,
|
||||
structItemFields,
|
||||
structItemOf,
|
||||
} from '../../core/properties/listItems';
|
||||
import { flatDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
addValue,
|
||||
createField,
|
||||
type FieldContext,
|
||||
NAME_WIDTH,
|
||||
primitiveDef,
|
||||
READ_ONLY,
|
||||
removeButton,
|
||||
} from '../inspector/fieldFactory';
|
||||
import { KIND_LABELS, kindMark, markPicker } from '../inspector/variableRows';
|
||||
import { kindMenu, markPicker } from '../inspector/variableRows';
|
||||
import type { EditorState } from '../state';
|
||||
import { type ListAccess, resolveList } from './slot';
|
||||
|
||||
@@ -103,9 +109,7 @@ export class ListEditor extends VStack {
|
||||
// Built on click, not once: which kinds fit depends on the list the tab is showing.
|
||||
this.addButton.onClick = () => {
|
||||
const kinds = this.access()?.kinds ?? LIST_ITEM_KINDS;
|
||||
new Menu(
|
||||
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => this.addItem(kind) })),
|
||||
).openBelow(this.addButton);
|
||||
new Menu(kindMenu(kinds, (kind) => this.addItem(kind))).openBelow(this.addButton);
|
||||
};
|
||||
footer.add(this.addButton);
|
||||
footer.add(new Spacer(), 1);
|
||||
@@ -210,13 +214,11 @@ export class ListEditor extends VStack {
|
||||
row.add(label);
|
||||
const mark = markPicker(
|
||||
kindMark(kind),
|
||||
access.kinds
|
||||
.filter((next) => next !== kind)
|
||||
.map((next) => ({
|
||||
label: KIND_LABELS[next],
|
||||
// Through `itemValue`, so a color keeps its number when it becomes one.
|
||||
onSelect: () => this.replaceItem(access, index, makeItem(next, itemValue(item))),
|
||||
})),
|
||||
// Through `untag`, so a color keeps its number when it becomes one.
|
||||
kindMenu(
|
||||
access.kinds.filter((next) => next !== kind),
|
||||
(next) => this.replaceItem(access, index, makeItem(next, untag(item))),
|
||||
),
|
||||
);
|
||||
if (tall) mark.minHeight = Theme.rowHeight;
|
||||
row.add(mark);
|
||||
@@ -224,12 +226,12 @@ export class ListEditor extends VStack {
|
||||
else {
|
||||
const field = createField(
|
||||
this.ctx,
|
||||
primitiveDef(String(index), kind),
|
||||
itemValue(item) ?? '',
|
||||
flatDef(String(index), kind),
|
||||
untag(item) ?? '',
|
||||
access.itemHandlers(index),
|
||||
);
|
||||
addValue(row, field);
|
||||
this.setters.push((next) => field.setValue(itemValue(next) ?? ''));
|
||||
this.setters.push((next) => field.setValue(untag(next) ?? ''));
|
||||
this.rowWidgets.push(rowWidgets(mark, field.widget));
|
||||
}
|
||||
row.add(removeButton(() => access.replace(access.value.filter((_, i) => i !== index))));
|
||||
@@ -255,14 +257,14 @@ export class ListEditor extends VStack {
|
||||
line.minHeight = Theme.rowHeight;
|
||||
const fieldMark = markPicker(
|
||||
kindMark(field.kind),
|
||||
FIELD_KINDS.filter((next) => next !== field.kind).map((next) => ({
|
||||
label: KIND_LABELS[next],
|
||||
onSelect: () =>
|
||||
kindMenu(
|
||||
FIELD_KINDS.filter((next) => next !== field.kind),
|
||||
(next) =>
|
||||
this.replaceItem(access, index, {
|
||||
...bundle,
|
||||
[field.key]: makeFlatItem(next, itemValue(bundle[field.key])),
|
||||
[field.key]: makeFlatItem(next, untag(bundle[field.key])),
|
||||
}),
|
||||
})),
|
||||
),
|
||||
);
|
||||
const name = nameField(field.key, (to) => {
|
||||
if (to && to !== field.key) this.replaceItem(access, index, renamedField(bundle, field.key, to));
|
||||
@@ -271,8 +273,8 @@ export class ListEditor extends VStack {
|
||||
line.add(name);
|
||||
const sub = createField(
|
||||
this.ctx,
|
||||
primitiveDef(field.key, field.kind),
|
||||
itemValue(bundle[field.key]) ?? '',
|
||||
flatDef(field.key, field.kind),
|
||||
untag(bundle[field.key]) ?? '',
|
||||
handlers.structFieldHandlers?.(field) ?? READ_ONLY,
|
||||
);
|
||||
setters.set(field.key, addValue(line, sub));
|
||||
@@ -290,7 +292,7 @@ export class ListEditor extends VStack {
|
||||
row.add(stack, 1);
|
||||
this.setters.push((next) => {
|
||||
const now = structItemOf(next);
|
||||
for (const field of fields) setters.get(field.key)?.(itemValue(now[field.key]) ?? '');
|
||||
for (const field of fields) setters.get(field.key)?.(untag(now[field.key]) ?? '');
|
||||
});
|
||||
this.rowWidgets.push(widgets);
|
||||
}
|
||||
@@ -319,11 +321,7 @@ function nameField(key: string, rename: (next: string) => void): TextField {
|
||||
|
||||
function addFieldMenu(add: (kind: FieldKind) => void): Dropdown {
|
||||
const button = new Dropdown('+ Field', { variant: 'ghost' });
|
||||
button.onClick = () => {
|
||||
new Menu(FIELD_KINDS.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => add(kind) }))).openBelow(
|
||||
button,
|
||||
);
|
||||
};
|
||||
button.onClick = () => new Menu(kindMenu(FIELD_KINDS, add)).openBelow(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,9 @@ 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 { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
|
||||
import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
|
||||
import {
|
||||
FIELD_KINDS,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
structOf,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { type PropertyDef, schemaForAsset, structOf } from '../../core/properties/PropertySchema';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FieldHandlers } from '../inspector/fieldFactory';
|
||||
import { currentProps, currentRef } from '../inspector/objectQueries';
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The kind table is the one place a kind is described. Everything a kind needs – its lists, its
|
||||
* name and mark, its empty value, its tag, its port – has to follow from the row alone, so each
|
||||
* check here walks the table rather than naming a kind.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { declaredPortType, portDefOf } from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { tagOf, untag } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
DECLARED_PORT_KINDS,
|
||||
emptyOf,
|
||||
FIELD_KINDS,
|
||||
isKindWith,
|
||||
isTaggedKind,
|
||||
KIND,
|
||||
KINDS,
|
||||
kindLabel,
|
||||
kindMark,
|
||||
LIST_ITEM_KINDS,
|
||||
plainKindOf,
|
||||
STRUCT_FIELD_KINDS,
|
||||
VARIABLE_KINDS,
|
||||
} from '../../src/core/properties/kinds';
|
||||
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const TAGGED = FIELD_KINDS.filter(isTaggedKind);
|
||||
/** A value of the type a tagged kind travels as, distinct from the kind's empty value. */
|
||||
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
|
||||
|
||||
describe('the kind table', () => {
|
||||
it('has a word and a mark for every kind a user can pick, and no two marks alike', () => {
|
||||
const picked = new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS]);
|
||||
for (const kind of picked) {
|
||||
expect(kindLabel(kind), kind).not.toBe(kind);
|
||||
expect(kindMark(kind), kind).not.toBe('?');
|
||||
}
|
||||
expect(new Set([...picked].map(kindMark)).size).toBe(picked.size);
|
||||
});
|
||||
|
||||
it('derives the kind lists from the roles, the depth rules on top', () => {
|
||||
for (const row of KINDS) {
|
||||
expect(VARIABLE_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('variable', row.kind));
|
||||
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
|
||||
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
|
||||
}
|
||||
expect(LIST_ITEM_KINDS).toEqual([...FIELD_KINDS, 'struct']);
|
||||
expect(STRUCT_FIELD_KINDS).toEqual([...FIELD_KINDS, 'list']);
|
||||
});
|
||||
|
||||
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
|
||||
for (const kind of FIELD_KINDS) {
|
||||
expect(coerceField(undefined, kind), kind).toBe(emptyOf(kind));
|
||||
expect(coerceField(emptyOf(kind), kind), kind).toBe(emptyOf(kind));
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a bare value as the untagged kind travelling its way', () => {
|
||||
for (const kind of FIELD_KINDS) {
|
||||
if (isTaggedKind(kind)) continue;
|
||||
expect(plainKindOf(KIND[kind].travels), kind).toBe(kind);
|
||||
expect(itemKindOf(emptyOf(kind)), kind).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tagged kind', () => {
|
||||
it('wraps what it reads, and reads back as itself', () => {
|
||||
for (const kind of TAGGED) {
|
||||
const item = makeItem(kind, sampleOf(kind));
|
||||
expect(tagOf(item), kind).toBe(kind);
|
||||
expect(itemKindOf(item), kind).toBe(kind);
|
||||
expect(untag(item), kind).toBe(sampleOf(kind));
|
||||
expect(listItemOf(item), kind).toEqual(item);
|
||||
// The bare value beside it is the plain kind – which is why the tag is needed at all.
|
||||
expect(itemKindOf(sampleOf(kind)), kind).toBe(plainKindOf(KIND[kind].travels));
|
||||
}
|
||||
});
|
||||
|
||||
it('survives a save and load in a list variable and in a node parameter', () => {
|
||||
for (const kind of TAGGED) {
|
||||
const item = makeItem(kind, sampleOf(kind));
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'items', 'list'));
|
||||
findAsset(f.project, behavior.id)!.props.items = [item, sampleOf(kind)];
|
||||
expect(findAsset(roundTrip(f.project), behavior.id)?.props.items, kind).toEqual([item, sampleOf(kind)]);
|
||||
|
||||
const loaded = deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'b',
|
||||
type: 'behavior',
|
||||
name: 'B',
|
||||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value: [item] } }], edges: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const asset = findAsset(loaded, 'b');
|
||||
expect(asset?.type === 'behavior' && asset.graph.nodes[0]?.params.value, kind).toEqual([item]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a declared port', () => {
|
||||
it('travels as the table says, and carries the field that edits it only when tagged', () => {
|
||||
for (const kind of DECLARED_PORT_KINDS) {
|
||||
expect(declaredPortType(kind), kind).toBe(KIND[kind].travels);
|
||||
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(isTaggedKind(kind) ? kind : undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the literal nodes', () => {
|
||||
it('are one per kind: named, typed and edited as the kind they hand out', () => {
|
||||
for (const [type, kind] of [
|
||||
['Number', 'number'],
|
||||
['Boolean', 'boolean'],
|
||||
['String', 'text'],
|
||||
['Texture', 'texture'],
|
||||
['List', 'list'],
|
||||
] as const) {
|
||||
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
|
||||
expect(def.label, type).toBe(kindLabel(kind));
|
||||
expect(def.outputs[0]?.type, type).toBe(KIND[kind].travels);
|
||||
expect(def.params[0]?.kind, type).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { colorItem, type PropertyValue, textureItem } from '../../src/core/model/Properties';
|
||||
import { type PropertyValue, tagged, untag } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { itemKindOf, itemValue, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||||
import { itemKindOf, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
/** A behavior with a list variable `stops`, placed once with an override of its own. */
|
||||
@@ -25,7 +25,7 @@ function withList(value: unknown = [1, 2]) {
|
||||
|
||||
describe('the kind of one list item', () => {
|
||||
it('is read off the item itself, a color through its tag', () => {
|
||||
expect([7, 'a', true, colorItem(0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||||
expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||||
'number',
|
||||
'text',
|
||||
'boolean',
|
||||
@@ -35,17 +35,17 @@ describe('the kind of one list item', () => {
|
||||
});
|
||||
|
||||
it('lets one list hold all of them at once, through a write and a read back', () => {
|
||||
const mixed = [7, 'tee', true, colorItem(0xff0000), { x: 1, y: 'two' }];
|
||||
const mixed = [7, 'tee', true, tagged('color', 0xff0000), { x: 1, y: 'two' }];
|
||||
const { f, behaviorId } = withList(mixed);
|
||||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed);
|
||||
});
|
||||
|
||||
/** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */
|
||||
it('keeps a color apart from the number beside it', () => {
|
||||
const { f, behaviorId } = withList([colorItem(0xff0000), 0xff0000]);
|
||||
const { f, behaviorId } = withList([tagged('color', 0xff0000), 0xff0000]);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
|
||||
expect(back.map(itemValue)).toEqual([0xff0000, 0xff0000]);
|
||||
expect(back.map(untag)).toEqual([0xff0000, 0xff0000]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -53,22 +53,22 @@ describe('the kind of one list item', () => {
|
||||
* sprite wears is one id whether it names a texture, one of its frames or an animation.
|
||||
*/
|
||||
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
|
||||
const { f, behaviorId } = withList([textureItem('anim-walk'), 'anim-walk']);
|
||||
const { f, behaviorId } = withList([tagged('texture', 'anim-walk'), 'anim-walk']);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
|
||||
expect(back.map(itemValue)).toEqual(['anim-walk', 'anim-walk']);
|
||||
expect(back.map(untag)).toEqual(['anim-walk', 'anim-walk']);
|
||||
});
|
||||
|
||||
it('turns a text into a texture item and back without losing the id', () => {
|
||||
expect(makeItem('texture', 'frame-26')).toEqual(textureItem('frame-26'));
|
||||
expect(makeItem('text', textureItem('frame-26'))).toBe('frame-26');
|
||||
expect(makeItem('texture', 'frame-26')).toEqual(tagged('texture', 'frame-26'));
|
||||
expect(makeItem('text', tagged('texture', 'frame-26'))).toBe('frame-26');
|
||||
});
|
||||
|
||||
/** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */
|
||||
it('stays a texture inside a struct item', () => {
|
||||
const { f, behaviorId } = withList([{ pic: textureItem('frame-26'), n: 1 }]);
|
||||
const { f, behaviorId } = withList([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||||
expect(back).toEqual([{ pic: textureItem('frame-26'), n: 1 }]);
|
||||
expect(back).toEqual([{ pic: tagged('texture', 'frame-26'), n: 1 }]);
|
||||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||||
'pic:texture',
|
||||
'n:number',
|
||||
@@ -121,12 +121,15 @@ describe('a list in a node parameter, where nothing is declared', () => {
|
||||
};
|
||||
|
||||
it('keeps a tagged texture an item of its own', () => {
|
||||
expect(paramOf([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']);
|
||||
expect(paramOf([tagged('texture', 'frame-26'), 'frame-26'])).toEqual([
|
||||
tagged('texture', 'frame-26'),
|
||||
'frame-26',
|
||||
]);
|
||||
});
|
||||
|
||||
it('and one inside a struct item as well', () => {
|
||||
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([
|
||||
{ pic: textureItem('frame-26'), n: 1 },
|
||||
expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([
|
||||
{ pic: tagged('texture', 'frame-26'), n: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { Fill } from '../../src/core/graph/nodes/data/Fill';
|
||||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||||
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
|
||||
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
|
||||
import { colorItem, sameValue, textOf, textureItem } from '../../src/core/model/Properties';
|
||||
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
|
||||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
|
||||
|
||||
@@ -99,24 +99,28 @@ describe('an untyped slot on load', () => {
|
||||
*/
|
||||
describe('the list nodes with tagged items', () => {
|
||||
it('hands a texture item out as the plain id it holds', () => {
|
||||
expect(run(ItemAt, { list: [textureItem('walk-up'), textureItem('fr-0-1')], index: 1 })).toEqual({
|
||||
expect(
|
||||
run(ItemAt, { list: [tagged('texture', 'walk-up'), tagged('texture', 'fr-0-1')], index: 1 }),
|
||||
).toEqual({
|
||||
item: 'fr-0-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('and a colour item as its number', () => {
|
||||
expect(run(ItemAt, { list: [colorItem(0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||||
expect(run(ItemAt, { list: [tagged('color', 0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||||
});
|
||||
|
||||
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
|
||||
it('finds a texture item by the id beside it', () => {
|
||||
expect(run(IndexOf, { list: [textureItem('a'), textureItem('b')], value: 'b' })).toEqual({ index: 1 });
|
||||
expect(run(IndexOf, { list: [tagged('texture', 'a'), tagged('texture', 'b')], value: 'b' })).toEqual({
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
|
||||
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
|
||||
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
|
||||
expect(run(SetItem, { list: [textureItem('a')], index: 0, value: 'b' })).toEqual({
|
||||
result: [textureItem('b')],
|
||||
expect(run(SetItem, { list: [tagged('texture', 'a')], index: 0, value: 'b' })).toEqual({
|
||||
result: [tagged('texture', 'b')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { colorItem, sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { coerceField } from '../../src/core/properties/listItems';
|
||||
import { coerceField } from '../../src/core/properties/kinds';
|
||||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||||
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
@@ -81,7 +81,7 @@ describe('a list as a value', () => {
|
||||
it('keeps a loaded item as the kind it is, kind by kind', () => {
|
||||
const def = variableDef('v', 'list', []);
|
||||
expect(coerceValue(def, ['1', 2, true])).toEqual(['1', 2, true]);
|
||||
expect(coerceValue(def, [colorItem(0xff0000)])).toEqual([colorItem(0xff0000)]);
|
||||
expect(coerceValue(def, [tagged('color', 0xff0000)])).toEqual([tagged('color', 0xff0000)]);
|
||||
// A colour only keeps its kind through the tag; as a bare number it is one.
|
||||
expect(coerceValue(def, [0xff0000])).toEqual([0xff0000]);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
withInlineDefault,
|
||||
} from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { textureItem } from '../../src/core/model/Properties';
|
||||
import { tagged } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { type PropertyDef, structOf, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
@@ -54,7 +54,7 @@ describe('a variable of kind color or texture', () => {
|
||||
describe('a struct field of kind texture', () => {
|
||||
/** Declared, so the value needs no tag: the field says what it is, as a color field does. */
|
||||
it('holds the plain id, and starts empty', () => {
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: textureItem('fr-0-1') })).toEqual({
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: tagged('texture', 'fr-0-1') })).toEqual({
|
||||
pic: 'fr-0-1',
|
||||
});
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' });
|
||||
|
||||
@@ -9,8 +9,9 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { colorItem } from '../../src/core/model/Properties';
|
||||
import { tagged } from '../../src/core/model/Properties';
|
||||
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||
import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds';
|
||||
import { variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { resolveList } from '../../src/editor/list/slot';
|
||||
import { EditorState, type ListSlot } from '../../src/editor/state';
|
||||
@@ -83,8 +84,8 @@ describe('the address a list is edited through', () => {
|
||||
});
|
||||
|
||||
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
|
||||
list().replace(['5', true, colorItem(0xff0000)]);
|
||||
expect(list().value).toEqual(['5', true, colorItem(0xff0000)]);
|
||||
list().replace(['5', true, tagged('color', 0xff0000)]);
|
||||
expect(list().value).toEqual(['5', true, tagged('color', 0xff0000)]);
|
||||
|
||||
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
@@ -260,8 +261,10 @@ describe('a list a struct field holds', () => {
|
||||
* the next write, which reads as the menu entry doing nothing at all.
|
||||
*/
|
||||
it('offers the flat kinds only, where the whole list has struct as well', () => {
|
||||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']);
|
||||
expect(list().kinds).toEqual(FIELD_KINDS);
|
||||
expect(list().kinds).not.toContain('struct');
|
||||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||||
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
|
||||
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
|
||||
expect(whole.kinds).toContain('struct');
|
||||
});
|
||||
});
|
||||
Whitespace-only changes.
@@ -12,3 +12,12 @@ Reduziere dass auf ein Minimum, wir brauchen das so "modular" wie möglich.
|
||||
Falls es schon so passt wie es ist, dann braucht man natürlich auch nichts machen, aber ich will nur auf nummer sicher gehen.
|
||||
|
||||
Falls dir bei der Arbeit noch andere Dinge auffallen die man besser/konstruktiver/schöner bauen kann, dann kannst du das natürlich gleich mitnehmen.
|
||||
|
||||
---
|
||||
|
||||
Umgesetzt (Umfang "alles", inkl. Literal-Nodes). Jede Wert-Art ist jetzt eine Zeile in
|
||||
`src/core/properties/kinds.ts`; die Art-Listen, Menues, Tags, Port-Felder und Literal-Nodes folgen
|
||||
daraus. Ein Tag ist `'$' + kind`, also generisch. Eine neue Art ist die Zeile plus – wenn sie etwas
|
||||
im Projekt benennt – ihr Picker (`fieldFactory.REFERENCES`, vom Typecheck erzwungen).
|
||||
Nicht angefasst, weil kein Typ-Thema: die `type !== 'exec'`-Guards und das Zwillingspaar
|
||||
`Variables.ts`/`Properties.ts`.
|
||||
@@ -56,7 +56,7 @@ export const propertiesOf = (objectType, objectBehavior) => ({
|
||||
|
||||
/**
|
||||
* A behavior variable declaration. `fields` is the shape of a `struct`; a `list` declares nothing,
|
||||
* its items each carry their own kind (a colour as `colorItem`).
|
||||
* its items each carry their own kind (a colour or a texture as `tagged`).
|
||||
*/
|
||||
export const variable = (key, kind, value, fields) => ({
|
||||
key,
|
||||
@@ -69,8 +69,8 @@ export const variable = (key, kind, value, fields) => ({
|
||||
/** A struct field declaration. */
|
||||
export const field = (key, kind = 'number') => ({ key, kind });
|
||||
|
||||
/** A colour as a list item: inside a list, 0xRRGGBB would otherwise be a plain number. */
|
||||
export const colorItem = (color) => ({ $color: color });
|
||||
|
||||
/** A picture as a list item – a texture, one of its frames or an animation, all one id. */
|
||||
export const textureItem = (id) => ({ $texture: id });
|
||||
/**
|
||||
* A list item of a kind JSON cannot show: inside a list a colour would be a plain number and a
|
||||
* texture id a plain text, so it wears its kind as a tag – `{ $color: 0xff0000 }`.
|
||||
*/
|
||||
export const tagged = (kind, value) => ({ [`$${kind}`]: value });
|
||||
+3
-12
@@ -19,16 +19,7 @@
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
edge,
|
||||
node,
|
||||
port,
|
||||
propertiesOf,
|
||||
sceneObject,
|
||||
textureItem,
|
||||
variable,
|
||||
written,
|
||||
} from './lib/builders.mjs';
|
||||
import { edge, node, port, propertiesOf, sceneObject, tagged, variable, written } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor);
|
||||
@@ -80,8 +71,8 @@ const walkAnimations = DIRECTIONS.map((dir, row) => ({
|
||||
}));
|
||||
|
||||
/** The two lists the graph indexes: one walk animation and one standing frame per direction. */
|
||||
const WALK_PICTURES = DIRECTIONS.map((dir) => textureItem(WALK(dir)));
|
||||
const IDLE_PICTURES = DIRECTIONS.map((_, row) => textureItem(idleFrame(row)));
|
||||
const WALK_PICTURES = DIRECTIONS.map((dir) => tagged('texture', WALK(dir)));
|
||||
const IDLE_PICTURES = DIRECTIONS.map((_, row) => tagged('texture', idleFrame(row)));
|
||||
|
||||
/**
|
||||
* Sets `facing` to a constant – one per direction, so no edge has to cross the graph. It is the row
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
* Usage: node tools/seed-tetris.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import {
|
||||
colorItem,
|
||||
edge,
|
||||
field,
|
||||
node,
|
||||
port,
|
||||
propertiesOf,
|
||||
sceneObject,
|
||||
tagged,
|
||||
variable,
|
||||
written,
|
||||
} from './lib/builders.mjs';
|
||||
@@ -891,7 +891,11 @@ const tetrisVariables = [
|
||||
variable('grid', 'list', EMPTY_GRID),
|
||||
variable('shapes', 'list', shapeTable()),
|
||||
// Tagged, because a list says nothing about its items and a colour is a number like any other.
|
||||
variable('colors', 'list', SHAPE_COLORS.map(colorItem)),
|
||||
variable(
|
||||
'colors',
|
||||
'list',
|
||||
SHAPE_COLORS.map((color) => tagged('color', color)),
|
||||
),
|
||||
variable('spawnX', 'list', spawnColumns()),
|
||||
// The falling piece and the one move being tried on it – four numbers each, and never apart.
|
||||
variable('piece', 'struct', NO_PIECE, PIECE_FIELDS),
|
||||
|
||||
Reference in new issue
Block a user