Files
feuerwerk/src/editor/inspector/fieldFactory.ts
T
adminandClaude Opus 5.5 0c5ac32b59
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 10m24s
Let structs and lists nest to any depth (#239)
Structs and lists were capped at two fixed levels (list of structs,
struct with a list field). Anything deeper was dropped silently on
load, in node settings and when written to a variable. The cap came
from an early design choice, not from a technical need, so it is
lifted completely instead of adding a third fixed level.

- One kind list NESTED_KINDS replaces ListItemKind and
  StructFieldKind. The flat special cases in listItems.ts are gone,
  every value goes through one recursive path.
- A struct inside a struct is declared, like a top-level struct.
  Values coming in over an edge are brought into that shape. Struct
  items in lists still take their fields from the value.
- New valuePath.ts addresses any nested spot by keys and indices.
  ListSlot.field is now ListSlot.path.
- Field commands (add, rename, remove) take the path to the struct.
  They change declaration, own value and all overrides in one undo
  step, as before on the top level.
- Inspector: "+ Field" offers Struct and List on every level. A
  nested struct is a header row with its own "+ Field", its fields
  are indented one step further.
- List tab: a list item shows "n items" and opens one level deeper.
  The title shows the path, e.g. "Listy · nums · [3]".
- Pack in Pack now writes a struct into a struct field instead of
  text. Objects are kept at any depth and stripped at every exit.
- A list edit in a struct field without an existing object override
  now starts from the shown value instead of an empty struct.
- Old projects load unchanged. Save, load and save again gives the
  same result.

Tests:
- ./check.sh: 172 files, 2498 tests, CHECK EXIT 0
- e2e 21 + 22: ALL PASSED, 90 checks (new 21d-iii, 22f-ii,
  21d and 22f updated)
- 20 new unit tests in nested-values.test.ts and
  nested-list-slot.test.ts, three tests that pinned the old cap
  turned around
- 6 counter-probes, each fails 1 to 8 tests, originals restored
  and checked with cmp
- Not covered: a nested struct inside a struct item in the list
  tab has no e2e click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:33:20 +02:00

627 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
layerIndexOf,
layerMaskOf,
layerName,
maskHas,
maskLabel,
maskWith,
} from '../../core/model/collisionLayers';
import {
assetChoiceLabel,
assetRefChoices,
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { keyName } from '../../core/keys';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import { tilemapLayerNames } from '../../core/model/tilemap';
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
import {
emptyFieldValue,
structOf,
flatDef,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { referencedAssetId } from '../../core/properties/references';
import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
CHEVRON_SLOT,
Checkbox,
ColorField,
drawChevron,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
NumberField,
type Size,
Spacer,
TextField,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
/**
* 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;
/**
* A press on the label of a reference row that names an asset (#221). Without it the label stays
* plain text – only the inspector knows what a click (ping) and a double-click (open) do.
*/
followReference?(assetId: string, e: FederatedPointerEvent): void;
}
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, `path` being the keys from the bundle down to it (a struct
* field nests further, #239). 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?: (path: readonly string[], 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;
}
/**
* The inspector's one column grid. A variable row is mark + name, a plain row a label spanning
* both, so every value starts at the same x. The mark column fits `≡Aa` (24 px measured) with air.
*/
export const MARK_WIDTH = 28;
export const NAME_WIDTH = 90;
export const FIELD_INDENT = 12;
const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH;
/**
* A name column `depth` indent steps in, narrowed by what the indent takes so the value after it
* starts on the same column at any depth. Deep enough, it stops shrinking and the values move right.
*/
export function nameWidth(depth: number): number {
return Math.max(MIN_NAME_WIDTH, NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap));
}
const MIN_NAME_WIDTH = 40;
/** 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[];
export const actionIf = (offered: unknown, label: string, onSelect: () => void): FieldAction[] =>
offered ? [{ label, onSelect }] : [];
/** One trailing button of a row: its picture (an SVG file), and what it currently offers. */
export interface FieldActionGroup {
icon: 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 };
}
/** Adds the trailing reset/apply/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: RowActionButton; available: FieldActions }[] = [];
const addActions = (icon: string, available: FieldActions): void => {
const button = new RowActionButton(icon);
// 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, available });
row.add(button);
};
for (const group of rowActions.extra ?? []) addActions(group.icon, group.available);
if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, rowActions.apply);
if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, rowActions.reset);
const refreshActions = (): void => {
let changed = false;
for (const { button, available } of actions) {
const count = available().length;
const visible = count > 0;
if (button.visible !== visible) {
button.visible = visible;
changed = true;
}
if (visible && button.setMenu(count > 1)) changed = true;
}
if (changed) row.relayout();
};
refreshActions();
return refreshActions;
}
/** A row action: its picture alone, or with a chevron once it offers more than one thing. */
class RowActionButton extends Button {
private readonly chevron = new Graphics();
private menu = false;
constructor(icon: string) {
super('', { variant: 'ghost', minWidth: Theme.controlHeight, icon });
this.addChild(this.chevron);
}
/** Whether the menu form changed – the row has to be laid out again then. */
setMenu(menu: boolean): boolean {
if (menu === this.menu) return false;
this.menu = menu;
this.invalidate();
return true;
}
override get opensMenu(): boolean {
return this.menu;
}
override measure(): Size {
const size = super.measure();
return this.menu ? { w: size.w + CHEVRON_SLOT, h: size.h } : size;
}
protected override trailingRoom(): number {
return this.menu ? CHEVRON_SLOT : 0;
}
override redraw(): void {
super.redraw();
if (this.menu) drawChevron(this.chevron, this.w, this.h, Theme.colors.textMuted);
else this.chevron.clear();
}
}
/**
* 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,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
rowActions: RowActions = {},
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
addValue(row, field);
const refreshActions = attachRowActions(row, rowActions);
const paintLabel = linkLabel(ctx, label);
let modified = false;
let target: string | null = null;
const retarget = (v: PropertyValue): void => {
target = referencedAssetId(ctx.project(), def, v);
paintLabel(target, modified);
};
retarget(value);
return {
row,
setValue: (v) => {
field.setValue(v);
retarget(v);
},
setModified(isModified) {
modified = isModified;
paintLabel(target, modified);
refreshActions();
},
};
}
/**
* Turns a row label into a link to an asset (#221): blue and underlined while it names one, a
* click pings it, a double-click opens it. Returns the repaint, to be called whenever the target
* or the modified state changes. Without `followReference` the label stays plain text.
*/
export function linkLabel(
ctx: FieldContext,
label: Label,
): (target: string | null, modified?: boolean) => void {
const follow = ctx.followReference?.bind(ctx);
let target: string | null = null;
if (follow)
label.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 0 && target) follow(target, e);
});
return (next, modified = false) => {
target = follow ? next : null;
// An override keeps its colour: that it deviates says more than that it can be followed.
label.setStyle(modified ? 'override' : target ? 'link' : 'muted');
label.setUnderline(target !== null);
label.eventMode = target ? 'static' : 'none';
};
}
export 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 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 struct field as a property definition – a list field becomes the same row a list property has,
* a struct field the same block a struct property has.
*/
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
if (field.kind === 'list') return { key: field.key, label, kind: 'list', default: [] };
if (field.kind === 'struct')
return {
key: field.key,
label,
kind: 'struct',
fields: field.fields,
default: structOf(field.fields, undefined),
};
return flatDef(field.key, field.kind, { label });
}
/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */
export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
const per = handlers.structFieldHandlers;
return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY;
}
/** 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: (project, def) =>
pictureChoices(project).filter(
(choice) => !(def.kind === 'texture' && def.uploadedOnly) || choice.asset.type === 'texture',
),
},
statemachine: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) =>
assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })),
},
sound: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
},
scene: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['scene']).map((a) => ({ id: a.id, label: a.name })),
},
animation: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['animation']).map((a) => ({ id: a.id, label: a.name })),
},
};
/** 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 items: MenuItem[] = [
{ label: ref.none, current: current === '', 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: choice.label,
current: choice.id === current,
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. */
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
}
/** 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 ?? (() => 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 own =
field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), own);
addValue(row, sub);
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 'object': {
// Nothing to pick: the object is written by a graph while the game runs, and never stored.
return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
}
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 'layer': {
// The names live in the project, not in the row: renaming a layer renames it everywhere.
const names = () => ctx.project().collisionLayers;
const labelOf = (v: PropertyValue) => layerName(names(), layerIndexOf(v));
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }))).openBelow(
button,
);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'layers': {
const names = () => ctx.project().collisionLayers;
const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names());
let mask = layerMaskOf(value);
const button = new Dropdown(labelOf(value));
// A tick per layer, and the menu stays open: picking several is the normal case here.
button.onClick = () => {
new Menu(
names().map((name, i) => ({
label: name,
checked: () => maskHas(mask, i),
keepOpen: true,
onSelect: () => {
mask = maskWith(mask, i, !maskHas(mask, i));
handlers.onChange(mask);
},
})),
).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
mask = layerMaskOf(v);
button.setText(labelOf(v));
},
};
}
case 'tileLayer': {
const labelOf = (v: PropertyValue) => textOf(v) || FIRST_TILE_LAYER;
const button = new Dropdown(labelOf(value));
button.onClick = () => {
const names = ['', ...tilemapLayerNames(ctx.project())];
new Menu(
names.map((name) => ({ label: name || FIRST_TILE_LAYER, onSelect: () => handlers.onChange(name) })),
{ searchable: true },
).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':
case 'texture':
case 'statemachine':
case 'sound':
case 'scene':
case 'animation':
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
}
}
/** 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 = keyName(event.key); // Shift + A is "a", the name the game hears it by
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' : '');
}