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 { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; import { emptyFieldValue, flatDef, 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; } /** * 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; /** 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 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 }; } /** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */ export function attachRowActions(row: HStack, rowActions: RowActions): () => void { const actions: { button: Button; glyph: string; available: FieldActions }[] = []; const addActions = (glyph: string, available: FieldActions): void => { // 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 refreshActions; } /** * 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); 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; } /** 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. */ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef { return field.kind === 'list' ? { key: field.key, label, kind: 'list', default: [] } : flatDef(field.key, field.kind, { label }); } /** 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 = { 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: textureRefChoices }, 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 })), }, }; /** 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 mark = (id: string, text: string) => (id === current ? `● ${text}` : text); const items: MenuItem[] = [ { label: mark('', ref.none), 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: mark(choice.id, choice.label), 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(); for (const field of def.fields) { const { row } = labelledRow(field.key); const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field)); 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 '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': case 'texture': case 'statemachine': case 'sound': case 'scene': 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 = 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' : ''); }