Files
feuerwerk/src/editor/inspector/fieldFactory.ts
T
adminandClaude Opus 5.5 668682c4ff Give a song, a track or an item a sound FX of its own (#355)
A sound FX for one place in a song had to be a project asset, which
then stood in every picker. Now every pick of a sound FX in a song
can make one the song keeps for that place alone, on top of the
embedded assets of the first two parts.

- Create sound FX sits at the end of the Sound FX row of the song,
  a track and an item, and of the menu of a track's FX button. It
  makes "Sound FX 1", 2, … per song with the Input wired to the
  Output, picks it and opens it. That is one undo step.
- Embed a copy, while the place names a project FX, gives the place
  a copy under the same name.
- While the place names its own, the menu shows it with the mark and
  offers Edit sound FX and Make project asset.
- Picking anything else lets it go in the same undo step. For a
  track or an item `settleEmbedded` did that already. For the song's
  own row `AssetPropertyCommand` now settles the song after `fx`.
- The mark `•` moved to the core. `assetRefLabel` puts it in front of
  an embedded asset, so the inspector rows show it like the timeline.
- `FieldHandlers.moreChoices` lets a caller end a reference menu
  with entries of its own. `embeddedPatternName` is `embeddedName`
  with a base now.

Tests:
- Unit own-fx (new): 6 green, 6 counter-checks each red
- Unit embedded and 80 neighbouring files: 1434 green
- e2e 65 (melektaus-atx): ALL PASSED, 51 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:32:41 +02:00

787 lines
29 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 { INHERIT_LABEL, INHERIT_LAYER, ownRenderLayer } from '../../core/model/renderLayers';
import {
assetChoiceLabel,
assetRefChoices,
assetRefLabel,
MASTER_LABEL,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { NO_FX } from '../../core/properties/assetFx';
import { busesInto, MASTER_BUS } from '../../core/properties/buses';
import { keyName } from '../../core/keys';
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs';
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 MenuEntry,
type MenuItem,
NumberField,
type Size,
Spacer,
TextField,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
import { InlineList, ListHead } from '../list/InlineList';
import type { ListAccess } from '../list/slot';
import type { ObjectPicker } from '../objectPick';
/**
* 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;
/** Items a list draws inline before "… N more" (#252); none means all of them. */
listLimit?: number;
/**
* What an object's place may name (#258), asked anew on every open. Only a placed object has a
* scene to pick from; without it the place shows "(null)".
*/
objectChoices?: () => ObjectChoice[];
/** Picks an object's place in the view instead (#276); without it the dropdown stands alone. */
objectPicker?: ObjectPicker;
}
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;
/**
* The list a `list` row draws inline (#252), re-resolved on every call; only the caller knows
* where the value it edits lives. Without it the row only says how many items there are.
*/
list?: () => ListAccess | null;
/**
* 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;
/**
* What the menu of a reference row ends with, asked on every open with the id it holds – a sound
* FX the song makes for that place alone (#355). Only the caller knows the place.
*/
moreChoices?: (current: string) => MenuEntry[];
}
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;
/** What the button says while it offers several actions; with one, it says that action. */
tooltip: 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, tooltip: string, available: FieldActions): void => {
const button = new RowActionButton(icon);
button.tooltip = () => rowActionTip(tooltip, available());
// 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.tooltip, group.available);
if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, 'Apply', rowActions.apply);
if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, '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;
}
/** The one thing a click would do, by its own name; the group's name where a click opens a menu. */
export function rowActionTip(group: string, offered: readonly FieldAction[]): string {
return offered.length === 1 ? offered[0]!.label : group;
}
/** 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: withBelow(row, field, 1),
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>;
/** What goes under the row rather than into it: a list's items (#252). */
below?: Widget;
}
/**
* The row alone, or – for a field with something below it – the row with that under it, `depth`
* indent steps in.
*/
function withBelow(row: Widget, field: Field, depth: number): Widget {
if (!field.below) return row;
const stack = new VStack({ gap: Theme.gap });
stack.add(row);
stack.add(indentedBy(field.below, depth));
return stack;
}
/** A widget `depth` indent steps in, as far as a struct field of that depth. */
export function indentedBy(widget: Widget, depth: number): HStack {
const line = new HStack({ gap: 0 });
if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap)));
line.add(widget, 1);
return line;
}
/** 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, tooltip: 'Remove', 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. */
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) => {
const extras = def.kind === 'asset' ? def : undefined;
// Asked as the menu opens: another bus may have been sent into this one meanwhile.
const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []);
return [
...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []),
...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []),
...assetRefChoices(project, extras?.assetTypes)
.filter((asset) => !left.has(asset.id))
.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 {
if (id === '' && def.kind === 'asset' && def.none) return def.none;
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 none = def.kind === 'asset' && def.none ? def.none : ref.none;
const labelOf = (id: string) => (id === '' ? none : ref.label(ctx.project(), id));
const button = new Dropdown(labelOf(current));
button.onClick = () => {
const items: MenuEntry[] = [
{ label: 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),
})),
...(handlers.moreChoices?.(current) ?? []),
];
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(labelOf(current));
},
};
}
/**
* "Target: <name> ▾" – another object of the same scene, or none (#258). Where there is no scene –
* on an asset – nothing can be picked, and a graph is what fills the place while the game runs.
* Beside the dropdown the eyedropper picks it in the view (#276); `name` is what its strip calls it.
*/
function objectField(ctx: FieldContext, name: string, value: PropertyValue, handlers: FieldHandlers): Field {
const choices = ctx.objectChoices;
if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} };
// A list item arrives unwrapped, as the id alone.
const idOf = (v: PropertyValue) => (typeof v === 'string' ? v : referencedId(v));
let current = idOf(value);
const labelOf = () => {
if (!current) return 'None';
return choices().find((c) => c.ref === current)?.label ?? '(missing)';
};
const button = new Dropdown(labelOf());
button.onClick = () => {
const pick = (ref: string) => () => handlers.onChange(objectReference(ref));
const items: MenuItem[] = [{ label: 'None', current: !current, onSelect: pick('') }];
for (const choice of choices())
items.push({
label: `${' '.repeat(choice.depth)}${choice.label}`,
current: choice.ref === current,
onSelect: pick(choice.ref),
});
// Searchable like the parent picker: it lists every object of the scene.
new Menu(items, { searchable: true }).openBelow(button);
};
const setValue = (v: PropertyValue) => {
current = idOf(v);
button.setText(labelOf());
};
const picker = ctx.objectPicker;
if (!picker) return { widget: button, grow: true, setValue };
const eyedropper = new Button('', {
variant: 'ghost',
minWidth: Theme.controlHeight,
icon: ROW_ACTION_ICON_SVG.eyedropper,
toggle: true,
tooltip: 'Pick an object in the scene',
});
eyedropper.onClick = () => {
picker.toggle(eyedropper, {
field: name,
current,
take: (ref) => handlers.onChange(objectReference(ref)),
ended: () => {
if (!eyedropper.destroyed) eyedropper.setToggled(false);
},
});
const picking = picker.picking(eyedropper);
eyedropper.setToggled(picking);
// Enter is the pick's now, not a press of the button that has the focus – this one, say.
if (picking) ui().focus.blur();
};
const line = new HStack({ gap: Theme.gap, align: 'center' });
line.add(button, 1);
line.add(eyedropper);
return { widget: line, grow: true, setValue };
}
/** 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). `name` is what the value is
* called where it sits – a struct field's is its bundle's and its own key.
*/
export function createField(
ctx: FieldContext,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
name = def.label,
): 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': {
const count = (v: PropertyValue) => (isList(v) ? v : []).length;
const source = handlers.list;
if (!source) {
const label = new Label(`${count(value)} items`, { style: 'muted', padding: Theme.fieldPadding });
return { widget: label, grow: true, setValue: (v) => label.setText(`${count(v)} items`) };
}
// The items go under the row: a board is hundreds of entries, so the inspector draws the
// first few and the head opens the rest in a tab of its own.
const list = new InlineList(ctx, source, { limit: ctx.listLimit });
const head = new ListHead(source, (kind) => list.addItem(kind));
list.head = head;
return {
widget: head,
grow: true,
setValue: (v) => {
head.setCount(count(v));
list.refresh();
},
below: list,
};
}
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,
`${name} · ${field.key}`,
);
addValue(row, sub);
fields.set(field.key, sub);
stack.add(withBelow(row, sub, 1));
}
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':
return objectField(ctx, name, value, handlers);
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 render = def.renderLayers === true;
const names = () => (render ? ctx.project().renderLayers : ctx.project().collisionLayers);
const labelOf = (v: PropertyValue) => {
if (!render) return layerName(names(), layerIndexOf(v));
const own = ownRenderLayer(v);
return own === INHERIT_LAYER ? INHERIT_LABEL : layerName(names(), own);
};
const button = new Dropdown(labelOf(value));
button.onClick = () => {
const layers = names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }));
const inherit = { label: INHERIT_LABEL, onSelect: () => handlers.onChange(INHERIT_LAYER) };
new Menu(render ? [inherit, ...layers] : layers).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'layers': {
const names = () => (def.renderLayers ? ctx.project().renderLayers : 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 get takesTab(): boolean {
return this.capturing;
}
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' : '');
}