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:
rootandClaude Opus 5 committed 2026-09-12 14:47:17 +02:00
1 parent 8a7185948d
commit 3ed8b68ecf
39 files changed
+699 -565

No files matched your search

+2 -6
View File
@@ -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 {
+8 -16
View File
@@ -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);
+65 -74
View File
@@ -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);
}
}
+11 -15
View File
@@ -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);
+7 -44
View File
@@ -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;
}
+29 -31
View File
@@ -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;
}
+2 -8
View File
@@ -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';