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>
787 lines
29 KiB
TypeScript
787 lines
29 KiB
TypeScript
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' : '');
|
||
}
|