Consolidate duplicated editor code, trim comments, refresh README

Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 11:00:30 +02:00
1 parent 9212e61c4c
commit 8f92a06c84
96 files changed
+999 -1261

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { oneStep } from '../../core/commands/CompoundCommand';
import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import {
@@ -23,7 +23,7 @@ export type SizeValues = Partial<Record<SizeAxis, number>>;
/** One undo step whether the action wrote one side or both. */
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands);
return oneStep('Set size', commands);
}
/** The pixel size as the schema would store it – null where a min/max would change a side. */
+14 -30
View File
@@ -4,6 +4,7 @@ import {
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextFieldName, nextVariableName } from '../../core/commands/naming';
import { structShapeOf } from '../../core/graph';
import {
AddStructFieldCommand,
AddVariableCommand,
@@ -22,7 +23,6 @@ import {
type StructFieldDef,
type StructFieldKind,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { markedAssetSchema } from '../../core/properties/deviation';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
@@ -30,16 +30,10 @@ 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, type VariableEditing } from './variableRows';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
import { buildVariable, KIND_LABELS, type VariableEditing } from './variableRows';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { assetValue } from './valueSpecs';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
list: 'List',
struct: 'Struct',
};
/** A freshly added field; a list one starts on numbers, the default `+ Variable ▸ List` picks too. */
const newField = (key: string, kind: StructFieldKind): StructFieldDef =>
kind === 'list' ? { key, kind, items: 'number' } : { key, kind };
@@ -54,18 +48,13 @@ export function assetSignature(project: Project, assetId: string): string {
}
export function defSignature(def: PropertyDef): string {
const fields = declaredFields(def)
.map((f) => `${f.key}:${f.kind}`)
.join('+');
const fields = (structShapeOf(def) ?? []).map((f) => `${f.key}:${f.kind}`).join('+');
// A list's item kind decides whether it has field rows at all, so it is structure too.
const items = def.kind === 'list' ? `/${def.items}` : '';
return `${def.key}:${def.kind}${items}${fields ? `(${fields})` : ''}`;
}
/** The fields a struct declares, or the item shape of a list of structs. */
function declaredFields(def: PropertyDef): readonly StructFieldDef[] {
return def.kind === 'struct' ? def.fields : def.kind === 'list' ? (def.fields ?? []) : [];
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
export function buildAssetSection(host: InspectorHost, assetId: string): void {
@@ -139,20 +128,9 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
}
}
/** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */
function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(host.state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
const spec = assetSpec(host, assetId, def.key);
const spec = assetValue(host.state, assetId, def.key);
return {
...previewingHandlers<Asset>(host, spec),
...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })),
@@ -167,7 +145,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const add = new Dropdown('+ Variable', { variant: 'ghost' });
add.onClick = () => {
const items = VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind],
label: KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior')
@@ -190,7 +168,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
renameField: (field, next) => run(new RenameStructFieldCommand(asset.id, def.key, field.key, next)),
removeField: (field) => run(new RemoveStructFieldCommand(asset.id, def.key, field.key)),
addField: (kind) =>
run(new AddStructFieldCommand(asset.id, def.key, newField(nextFieldName(declaredFields(def)), kind))),
run(
new AddStructFieldCommand(
asset.id,
def.key,
newField(nextFieldName(structShapeOf(def) ?? []), kind),
),
),
};
const value = values[def.key] ?? def.default;
host.fields.set(def.key, buildVariable(host, def, value, assetHandlers(host, asset.id, def), { edit }));
+19 -20
View File
@@ -110,10 +110,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
return { row, label };
}
/**
* Adds the trailing ↺/↑/extra buttons to a row and returns the refresh that re-evaluates them.
* Separate from `createFieldRow` because a struct block wears the same buttons on its head row.
*/
/** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
@@ -157,13 +154,8 @@ export function attachRowActions(row: HStack, rowActions: RowActions): () => voi
}
/**
* Maps one PropertySchema entry to a labelled row containing the matching widget.
* The inspector is entirely generated through this function: adding a new property
* kind means adding a case here, adding a property means adding a schema line.
*
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
* Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
* no button, one a button, several a menu.
*/
export function createFieldRow(
ctx: FieldContext,
@@ -174,8 +166,7 @@ export function createFieldRow(
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
addValue(row, field);
const refreshActions = attachRowActions(row, rowActions);
@@ -198,6 +189,19 @@ interface Field {
parts?: ReadonlyMap<string, Widget>;
}
/** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */
export function addValue(row: HStack, field: Field): (value: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
export function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
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) {
@@ -263,18 +267,13 @@ export function createField(
case 'struct': {
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
// one writes the whole bundle, which is why the handlers come per field from the caller.
const per = handlers.structFieldHandlers;
if (!per) {
const summary = new Label(textOf(value), { style: 'muted' });
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
}
const per = handlers.structFieldHandlers ?? (() => READ_ONLY);
const stack = new VStack({ gap: Theme.gap });
const fields = new Map<string, Field>();
for (const field of def.fields) {
const { row } = labelledRow(field.key);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
row.add(sub.widget, sub.grow ? 1 : 0);
if (!sub.grow) row.add(new Spacer(), 1);
addValue(row, sub);
fields.set(field.key, sub);
stack.add(row);
}
+2 -3
View File
@@ -4,7 +4,7 @@ import {
SetFrameRectCommand,
} from '../../core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { findAssetOf, type Project } from '../../core/model/Project';
import { frameRect, type FrameRect, normalizeFrameRect, setFrameRect } from '../../core/model/texture';
import { Button, TextField } from '../../ui';
import { createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
@@ -121,6 +121,5 @@ function currentFrame(project: Project, assetId: string, frameId: string): Textu
}
function currentTexture(project: Project, assetId: string): TextureAsset | undefined {
const asset = findAsset(project, assetId);
return asset?.type === 'texture' ? asset : undefined;
return findAssetOf(project, assetId, 'texture');
}
+8 -26
View File
@@ -1,8 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import { RemoveNodeCommand } from '../../core/commands/graphCommands';
import {
callProblem,
declaresVariablePorts,
type Graph,
edgesInto,
type GraphNode,
inlineInputDef,
@@ -12,18 +11,19 @@ import {
} from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { behaviorLookup } from '../../core/model/behaviorPrograms';
import { findAsset, type Project } from '../../core/model/Project';
import { findAssetOf, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { buildPortSection, buildVariablePortSection } from './portSection';
import { listOpener, previewingHandlers } from './previewHandlers';
import { currentNode, nodeParamValue } from './valueSpecs';
/** Structure-defining facts of a node selection: ports, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return 'missing';
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const lookup = behaviorLookup(project);
@@ -41,8 +41,8 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
/** Description, settings (params) and the inline values of unconnected data inputs. */
export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: string): void {
const asset = findAsset(host.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
const asset = findAssetOf(host.state.project, assetId, 'behavior');
if (!asset) return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
if (!node || !def) return;
@@ -126,25 +126,7 @@ function paramHandlers(
): FieldHandlers {
const key = def.key;
return {
...previewingHandlers<GraphNode>(host, {
target: () => currentNode(host.state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
}),
...previewingHandlers<GraphNode>(host, nodeParamValue(host.state, assetId, nodeId, key)),
...listOpener(host.state, def, () => ({ kind: 'node', assetId, nodeId, key })),
};
}
/** The node together with the graph it sits in, because its ports are derived from both. */
function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAsset(project, assetId);
if (asset?.type !== 'behavior') return undefined;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
return node && { node, graph: asset.graph };
}
+8 -17
View File
@@ -38,14 +38,15 @@ import {
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { addCollider, canAddCollider, createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
import { createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import type { ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, type PreviewedValue, previewingHandlers, structFields } from './previewHandlers';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -230,7 +231,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type !== 'collider' && !target) {
const stored = findObjectHolder(project, holderId);
if (stored && canAddCollider(project, stored, object)) {
addGap(host);
host.body.add(
new Button('+ Add collider', {
@@ -292,12 +294,8 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
}
/**
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards).
*
* Inside a container a value has two bases – the template it follows and, one step further up,
* the asset. Reset always goes to the nearest one, so content of a placed container offers only
* "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
* One editable property of a placed object. Reset always goes to the nearest base (the template inside
* a container), while "Apply to asset" reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
@@ -309,14 +307,7 @@ function propertyRow(
variable = false,
): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec: PreviewedValue<ObjectRef> = {
target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key],
current: () => currentProps(host.state, holderId, objectId)?.[def.key],
write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
};
const overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
const fieldSlot = (field: string): ListSlot => ({
kind: 'object',
holderId,
+35 -57
View File
@@ -10,7 +10,6 @@ import {
BEHAVIOR_START,
DECLARED_PORT_KINDS,
type DeclaredPort,
type DeclaredPortKind,
type GraphNode,
isTerminalNode,
missingFieldPorts,
@@ -18,26 +17,20 @@ import {
variablePortKind,
} from '../../core/graph';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme } from '../../ui';
import {
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host';
const KIND_LABELS: Record<DeclaredPortKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
object: 'Object',
list: 'List',
struct: 'Struct',
};
const KIND_MARKS: Record<DeclaredPortKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
object: '◇',
list: '≡',
struct: '⊞',
};
import { KIND_LABELS, kindMark, markLabel } from './variableRows';
/**
* The ports of a Get/Set variables node: each one *is* a variable of the behavior, so it is picked
@@ -75,11 +68,6 @@ export function buildVariablePortSection(
for (const port of ports) {
const known = variables.some((v) => v.key === port.key);
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
@@ -87,16 +75,7 @@ export function buildVariablePortSection(
);
new Menu(items).openBelow(choose);
};
row.add(choose, 1);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
portRow(host, assetId, node, port, choose);
if (!known) addNote(host, `"${port.key}" is not a variable of this behavior.`, 'warning');
}
}
@@ -143,31 +122,30 @@ export function buildPortSection(
);
for (const port of ports) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(KIND_MARKS[port.kind], { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
new TextField(port.key, {
width: 90,
onChange: (next) =>
host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
}),
1,
);
row.add(new Spacer());
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemovePortCommand(assetId, node.id, port.key)),
}),
);
host.body.add(row);
const name = new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
portRow(host, assetId, node, port, name);
}
}
function portRow(
host: InspectorHost,
assetId: string,
node: GraphNode,
port: DeclaredPort,
name: Widget,
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(kindMark(port.kind)));
row.add(name, 1);
row.add(new Spacer());
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
host.body.add(row);
}
/**
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
* typing them again. Only offered where there is a shape to copy; a field the node already has
+2 -4
View File
@@ -78,10 +78,8 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
}
/**
* Row handlers that preview into the model while editing and take that preview back before
* committing. A command records the value it finds at execute time, so committing right away
* would make the last previewed frame the undo target: the value from before the preview is
* written back first – including "the key did not exist", via `remove`.
* Row handlers that preview into the model and restore the pre-preview value (or its absence, via
* `remove`) before committing, since a command records whatever it finds at execute time as its undo target.
*/
export function previewingHandlers<Target, Model = PropertyValue>(
host: { state: EditorState },
+64
View File
@@ -0,0 +1,64 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import type { Asset } from '../../core/model/Asset';
import type { ObjectRef } from '../../core/model/instance';
import { findAsset, findAssetOf, type Project } from '../../core/model/Project';
import type { EditorState } from '../state';
import { currentProps, currentRef } from './objectQueries';
import type { PreviewedValue } from './previewHandlers';
/** Where one value lives, shared by the inspector and the List tab so both write it the same way. */
export function assetValue(state: EditorState, assetId: string, key: string): PreviewedValue<Asset> {
return {
target: () => findAsset(state.project, assetId),
read: (asset) => asset.props[key],
write: (asset, value) => (asset.props[key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, key, value),
};
}
export function overrideValue(
state: EditorState,
holderId: string,
objectId: string,
key: string,
): PreviewedValue<ObjectRef> {
return {
target: () => currentRef(state, holderId, objectId),
read: (ref) => ref.object.overrides[key],
current: () => currentProps(state, holderId, objectId)?.[key],
write: (ref, value) => ref.setOverride(key, value),
remove: (ref) => ref.setOverride(key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, key, value),
};
}
export function nodeParamValue(
state: EditorState,
assetId: string,
nodeId: string,
key: string,
): PreviewedValue<GraphNode> {
return {
target: () => currentNode(state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
};
}
/** The node together with its graph, because its ports are derived from both. */
export function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAssetOf(project, assetId, 'behavior');
if (!asset) return undefined;
const node = findNode(asset.graph, nodeId);
return node && { node, graph: asset.graph };
}
+29 -44
View File
@@ -1,3 +1,4 @@
import type { DeclaredPortKind } from '../../core/graph';
import type { PropertyValue } from '../../core/model/Properties';
import {
FIELD_KINDS,
@@ -8,10 +9,12 @@ import {
type StructDef,
type StructFieldDef,
type StructFieldKind,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme, type Widget } from '../../ui';
import type { ListDef } from '../list/slot';
import {
addValue,
attachRowActions,
createField,
FIELD_INDENT,
@@ -20,26 +23,34 @@ import {
type FieldRow,
MARK_WIDTH,
NAME_WIDTH,
READ_ONLY,
removeButton,
type RowActions,
structFieldValue,
} from './fieldFactory';
import { addNote, type InspectorHost } from './host';
// Keyed by the wide `PropertyDef['kind']` because variables are `PropertyDef`s, but only the
// VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
struct: '⊞',
};
type ShownKind = VariableKind | ListItemKind | StructFieldKind | DeclaredPortKind;
const LIST_ITEM_MARKS: Record<ListItemKind, string> = {
/** 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: '◧',
list: '≡',
struct: '⊞',
object: '◇',
};
export const KIND_LABELS: Record<ShownKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
struct: 'Struct',
object: 'Object',
};
const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
@@ -50,21 +61,10 @@ const LIST_ITEM_LABELS: Record<ListItemKind, string> = {
struct: 'Structs',
};
const FIELD_KIND_MARKS: Record<StructFieldKind, string> = {
number: '#',
boolean: '✓',
text: 'Aa',
color: '◧',
list: '≡',
};
const FIELD_KIND_LABELS: Record<StructFieldKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
color: 'Color',
list: 'List',
};
/** 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] ?? '?';
}
/** Changing a declaration – offered only on the asset that declares it. */
export interface VariableEditing {
@@ -82,8 +82,6 @@ export interface VariableOptions {
actions?: RowActions;
}
const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/**
* One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the
* shape belongs to the asset. Adds its rows to the body itself and returns the first.
@@ -169,7 +167,7 @@ function itemShapeRows(host: InspectorHost, def: ListDef, edit: VariableEditing)
function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack {
const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next)));
return gridRow(1, markLabel(FIELD_KIND_MARKS[field.kind]), name);
return gridRow(1, markLabel(KIND_MARKS[field.kind]), name);
}
/** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */
@@ -194,22 +192,13 @@ function nameCell(text: string, depth: number, rename: ((next: string) => void)
: new Label(text, { style: 'muted', width, padding: Theme.fieldPadding });
}
function addValue(
row: HStack,
field: { widget: Widget; grow: boolean; setValue: (v: PropertyValue) => void },
): (v: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
function markOf(def: PropertyDef, edit: VariableEditing | undefined): Widget {
if (def.kind !== 'list') return markLabel(VARIABLE_KIND_MARKS[def.kind] ?? '?');
const glyph = `≡${LIST_ITEM_MARKS[def.items]}`;
if (def.kind !== 'list') return markLabel(kindMark(def.kind));
const glyph = `≡${KIND_MARKS[def.items]}`;
return edit ? itemKindPicker(glyph, def.items, edit) : markLabel(glyph);
}
function markLabel(glyph: string): Label {
export function markLabel(glyph: string): Label {
return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH });
}
@@ -244,12 +233,8 @@ function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing):
const add = new Dropdown('+ Field', { variant: 'ghost' });
add.onClick = () => {
new Menu(
kinds.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
kinds.map((kind) => ({ label: KIND_LABELS[kind], onSelect: () => edit.addField(kind) })),
).openBelow(add);
};
return add;
}
function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}