A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.
Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.
Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.
Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
|
||
import type { Project } from '../../core/model/Project';
|
||
import {
|
||
assetChoiceLabel,
|
||
assetRefChoices,
|
||
assetRefLabel,
|
||
NO_ASSET_LABEL,
|
||
} from '../../core/properties/assetRef';
|
||
import {
|
||
emptyField,
|
||
type FieldKind,
|
||
type PropertyDef,
|
||
type StructFieldDef,
|
||
} from '../../core/properties/PropertySchema';
|
||
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||
import {
|
||
Button,
|
||
Checkbox,
|
||
ColorField,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
NumberField,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
ui,
|
||
VStack,
|
||
type Widget,
|
||
} from '../../ui';
|
||
|
||
/**
|
||
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
|
||
* and to list what the picker may offer. A getter rather than the project itself – loading and
|
||
* entering play mode replace that object.
|
||
*/
|
||
export interface FieldContext {
|
||
project(): Project;
|
||
}
|
||
|
||
export interface FieldHandlers {
|
||
/** Live value while dragging/typing – apply as a preview, no undo entry. */
|
||
onInput: (value: PropertyValue) => void;
|
||
/** Final value – commit as a command. */
|
||
onChange: (value: PropertyValue) => void;
|
||
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
|
||
openList?: () => void;
|
||
/**
|
||
* Handlers for one field of a struct. Writing a field means writing the whole bundle, so only
|
||
* the caller – which knows where that bundle lives – can build them. Without them a struct row
|
||
* can only show what it holds.
|
||
*/
|
||
structFieldHandlers?: (field: StructFieldDef) => FieldHandlers;
|
||
}
|
||
|
||
export interface FieldRow {
|
||
row: Widget;
|
||
/** Push a model value into the widget (no events fired). */
|
||
setValue(value: PropertyValue): void;
|
||
/**
|
||
* Colour the label as deviating from its base value (an override on scene objects, a
|
||
* non-default value on assets) and re-evaluate which actions currently apply.
|
||
*/
|
||
setModified(modified: boolean): void;
|
||
}
|
||
|
||
/** Width of the label column every inspector row aligns to. */
|
||
const FIELD_LABEL_WIDTH = 64;
|
||
|
||
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
|
||
export interface FieldAction {
|
||
label: string;
|
||
onSelect: () => void;
|
||
}
|
||
|
||
/**
|
||
* The actions available *right now* – asked again on every refresh and on every click.
|
||
* Anything that would not change the value is left out, so a row never offers a dead entry.
|
||
*/
|
||
export type FieldActions = () => FieldAction[];
|
||
|
||
/** One trailing button of a row: its glyph, and what it currently offers. */
|
||
export interface FieldActionGroup {
|
||
glyph: string;
|
||
available: FieldActions;
|
||
}
|
||
|
||
export interface RowActions {
|
||
reset?: FieldActions;
|
||
apply?: FieldActions;
|
||
/** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */
|
||
extra?: readonly FieldActionGroup[];
|
||
}
|
||
|
||
/** A row starting with the fixed-width, muted label column. */
|
||
export function labelledRow(text: string): { row: HStack; label: Label } {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
const label = new Label(text, { style: 'muted' });
|
||
label.minWidth = FIELD_LABEL_WIDTH;
|
||
row.add(label);
|
||
return { row, label };
|
||
}
|
||
|
||
/**
|
||
* 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 (↺ ▾ / ↑ ▾).
|
||
*/
|
||
export function createFieldRow(
|
||
ctx: FieldContext,
|
||
def: PropertyDef,
|
||
value: PropertyValue,
|
||
handlers: FieldHandlers,
|
||
rowActions: RowActions = {},
|
||
): 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);
|
||
|
||
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
|
||
const addActions = (glyph: string, available: FieldActions): void => {
|
||
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
|
||
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
|
||
// Asked again on click: the menu must not act on a state that has moved on since the
|
||
// last refresh (an undo between refreshes, a value edited in the viewport).
|
||
button.onClick = () => {
|
||
const items = available();
|
||
if (items.length === 0) return;
|
||
if (items.length === 1) return items[0]!.onSelect();
|
||
new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button);
|
||
};
|
||
button.visible = false;
|
||
actions.push({ button, glyph, available });
|
||
row.add(button);
|
||
};
|
||
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
|
||
if (rowActions.apply) addActions('↑', rowActions.apply);
|
||
if (rowActions.reset) addActions('↺', rowActions.reset);
|
||
|
||
const refreshActions = (): void => {
|
||
let changed = false;
|
||
for (const { button, glyph, available } of actions) {
|
||
const count = available().length;
|
||
const text = count > 1 ? `${glyph} ▾` : glyph;
|
||
const visible = count > 0;
|
||
if (button.visible !== visible) {
|
||
button.visible = visible;
|
||
changed = true;
|
||
}
|
||
if (visible && button.text !== text) {
|
||
button.setText(text);
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) row.relayout();
|
||
};
|
||
refreshActions();
|
||
|
||
return {
|
||
row,
|
||
setValue: field.setValue,
|
||
setModified(modified) {
|
||
label.setStyle(modified ? 'override' : 'muted');
|
||
refreshActions();
|
||
},
|
||
};
|
||
}
|
||
|
||
interface Field {
|
||
widget: Widget;
|
||
/** Whether the widget should take the remaining row width. */
|
||
grow: boolean;
|
||
setValue(value: PropertyValue): void;
|
||
/** The per-field widgets of a struct, so a caller can address one of them. */
|
||
parts?: ReadonlyMap<string, Widget>;
|
||
}
|
||
|
||
/** A primitive as a property definition, so one widget set serves a list item and a struct field. */
|
||
export function primitiveDef(key: string, kind: 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 };
|
||
}
|
||
}
|
||
|
||
export type PrimitiveDef = Extract<PropertyDef, { kind: FieldKind }>;
|
||
|
||
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
|
||
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
|
||
return (isStruct(value) ? value[field.key] : undefined) ?? emptyField(field.kind);
|
||
}
|
||
|
||
/** The bare widget for a property definition (no label, no reset). */
|
||
export function createField(
|
||
ctx: FieldContext,
|
||
def: PropertyDef,
|
||
value: PropertyValue,
|
||
handlers: FieldHandlers,
|
||
): Field {
|
||
switch (def.kind) {
|
||
case 'number': {
|
||
const field = new NumberField(Number(value), {
|
||
min: def.min,
|
||
max: def.max,
|
||
step: def.step ?? 1,
|
||
decimals: def.decimals,
|
||
onInput: handlers.onInput,
|
||
onChange: handlers.onChange,
|
||
});
|
||
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
|
||
}
|
||
case 'text': {
|
||
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
|
||
}
|
||
case 'color': {
|
||
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
|
||
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
|
||
}
|
||
case 'list': {
|
||
// A board is hundreds of entries: the row says how many and hands the editing to the
|
||
// List tab, where a row per item has the space for it.
|
||
const count = (v: PropertyValue) => `${(isList(v) ? v : []).length} items`;
|
||
const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList });
|
||
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
|
||
}
|
||
case 'struct': {
|
||
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
|
||
// one writes the whole bundle, which is why the handlers come per field from the caller.
|
||
const per = handlers.structFieldHandlers;
|
||
if (!per) {
|
||
const summary = new Label(textOf(value), { style: 'muted' });
|
||
return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) };
|
||
}
|
||
const stack = new VStack({ gap: Theme.gap });
|
||
const fields = new Map<string, Field>();
|
||
for (const field of def.fields) {
|
||
const { row } = labelledRow(field.key);
|
||
const sub = createField(
|
||
ctx,
|
||
primitiveDef(field.key, field.kind),
|
||
structFieldValue(value, field),
|
||
per(field),
|
||
);
|
||
row.add(sub.widget, sub.grow ? 1 : 0);
|
||
if (!sub.grow) row.add(new Spacer(), 1);
|
||
fields.set(field.key, sub);
|
||
stack.add(row);
|
||
}
|
||
return {
|
||
widget: stack,
|
||
grow: true,
|
||
setValue: (v) => {
|
||
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
|
||
},
|
||
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
|
||
};
|
||
}
|
||
case 'boolean': {
|
||
const field = new Checkbox(Boolean(value));
|
||
field.onChange = handlers.onChange;
|
||
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
|
||
}
|
||
case 'enum': {
|
||
const labelOf = (v: PropertyValue) =>
|
||
def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—');
|
||
const button = new Dropdown(labelOf(value));
|
||
button.onClick = () => {
|
||
new Menu(
|
||
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
|
||
).openBelow(button);
|
||
};
|
||
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
|
||
}
|
||
case 'key': {
|
||
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));
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Shows a key name; click, then press a key to change it (Escape cancels). */
|
||
class KeyCaptureButton extends Button {
|
||
private key: string;
|
||
private capturing = false;
|
||
|
||
constructor(
|
||
key: string,
|
||
private readonly onCapture: (key: string) => void,
|
||
) {
|
||
super(keyLabel(key));
|
||
this.key = key;
|
||
this.onClick = () => {
|
||
this.capturing = true;
|
||
this.setText('Press a key…');
|
||
ui().focus.focus(this);
|
||
};
|
||
}
|
||
|
||
setKey(key: string): void {
|
||
this.key = key;
|
||
if (!this.capturing) this.setText(keyLabel(key));
|
||
}
|
||
|
||
override onKeyDown(event: KeyboardEvent): boolean {
|
||
if (!this.capturing) return super.onKeyDown(event);
|
||
event.preventDefault();
|
||
this.capturing = false;
|
||
if (event.key === 'Escape') {
|
||
this.setText(keyLabel(this.key));
|
||
return true;
|
||
}
|
||
if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) {
|
||
this.capturing = true;
|
||
return true;
|
||
}
|
||
this.key = event.key;
|
||
this.setText(keyLabel(this.key));
|
||
this.onCapture(this.key);
|
||
return true;
|
||
}
|
||
|
||
override onBlur(): void {
|
||
super.onBlur();
|
||
if (this.capturing) {
|
||
this.capturing = false;
|
||
this.setText(keyLabel(this.key));
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */
|
||
export function keyLabel(key: string): string {
|
||
if (key === ' ') return 'Space';
|
||
if (key === '') return '—';
|
||
return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : '');
|
||
}
|