import type { PropertyValue } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { assetChoiceLabel, assetRefChoices, assetRefLabel, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; import type { PropertyDef } 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, type Widget, } from '../../ui'; /** * What a field needs beyond its own value: the project, to resolve an asset reference (an id) * to a name and to list what the picker may offer. * * A getter rather than the project itself – loading a document and entering play mode replace * the object, and a row that outlives either must not keep answering from the old one. */ 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; } export interface FieldRow { row: Widget; /** Push a model value into the widget (no events fired). */ setValue(value: PropertyValue): void; /** * Highlight the row as deviating from its base value (an override on scene objects, a * non-default value on assets) and re-evaluate which actions currently apply. */ setModified(modified: boolean): void; } /** Width of the label column every inspector row aligns to. */ const FIELD_LABEL_WIDTH = 64; /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ 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[]; /** A row starting with the fixed-width, muted label column. */ export function labelledRow(text: string): { row: HStack; label: Label } { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; const label = new Label(text, { style: 'muted' }); label.minWidth = FIELD_LABEL_WIDTH; row.add(label); return { row, label }; } /** * Maps one PropertySchema entry to a labelled row containing the matching widget. * The inspector is entirely generated through this function: adding a new property * kind means adding a case here, adding a property means adding a schema line. * * A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to * its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry * means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾). */ export function createFieldRow( ctx: FieldContext, def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions, ): FieldRow { const { row, label } = labelledRow(def.label); const field = createField(ctx, def, value, handlers); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); const actions: { button: Button; glyph: string; available: FieldActions }[] = []; const addActions = (glyph: string, available: FieldActions): void => { const button = new Button(glyph, { variant: 'ghost', width: 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); }; if (apply) addActions('↑', apply); if (reset) addActions('↺', reset); const refreshActions = (): void => { let changed = false; for (const { button, glyph, available } of actions) { const count = available().length; const text = count > 1 ? `${glyph} ▾` : glyph; const visible = count > 0; if (button.visible !== visible) { button.visible = visible; changed = true; } if (visible && button.text !== text) { button.setText(text); changed = true; } } if (changed) row.relayout(); }; refreshActions(); return { row, setValue: field.setValue, setModified(modified) { label.setStyle(modified ? 'bodyBold' : 'muted'); refreshActions(); }, }; } interface Field { widget: Widget; /** Whether the widget should take the remaining row width. */ grow: boolean; setValue(value: PropertyValue): void; } /** 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(String(value), { onInput: handlers.onInput, onChange: handlers.onChange }); return { widget: field, grow: true, setValue: (v) => field.setValue(String(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 '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 === String(v))?.label ?? (String(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(String(value), (key) => handlers.onChange(key)); return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) }; } case 'asset': { // The current id is kept here rather than read back off the label: the label is a name, // two assets may share one, and a dangling reference has no name at all. let current = String(value); const button = new Dropdown(assetRefLabel(ctx.project(), current)); button.onClick = () => { const mark = (id: string, text: string) => (id === current ? `● ${text}` : text); const items: MenuItem[] = [ { label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') }, // Built on click, not once per row: assets are created, renamed and deleted while // the inspector stands, and the picker has to show the project as it is now. ...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({ label: mark(asset.id, assetChoiceLabel(asset)), onSelect: () => handlers.onChange(asset.id), })), ]; // Searchable: the list grows with the project, like the parent picker. new Menu(items, { searchable: true }).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => { current = String(v); button.setText(assetRefLabel(ctx.project(), current)); }, }; } case 'texture': { // The id is kept here for the same reason as in the asset case, and more urgently: a // frame's label repeats its sheet's name, so several entries can read identically. let current = String(value); const button = new Dropdown(textureRefLabel(ctx.project(), current)); button.onClick = () => { const mark = (id: string, text: string) => (id === current ? `● ${text}` : text); const items: MenuItem[] = [ { label: mark('', NO_TEXTURE_LABEL), onSelect: () => handlers.onChange('') }, // Built on click, not once per row: textures are uploaded and frames are cut, renamed // and sliced away while the inspector stands, and the picker has to show what exists now. ...textureRefChoices(ctx.project()).map((choice) => ({ label: mark(choice.id, choice.label), onSelect: () => handlers.onChange(choice.id), })), ]; // Searchable: one sliced sheet alone contributes an entry per frame. The entries read // "Sheet / Frame", so the sheet's own name gathers its frames into one block of the // list and typing either name finds them. new Menu(items, { searchable: true }).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => { current = String(v); button.setText(textureRefLabel(ctx.project(), current)); }, }; } } } /** Shows a key name; click, then press a key to change it (Escape cancels). */ class KeyCaptureButton extends Button { private key: string; private capturing = false; constructor( key: string, private readonly onCapture: (key: string) => void, ) { super(keyLabel(key)); this.key = key; this.onClick = () => { this.capturing = true; this.setText('Press a key…'); ui().focus.focus(this); }; } setKey(key: string): void { this.key = key; if (!this.capturing) this.setText(keyLabel(key)); } override onKeyDown(event: KeyboardEvent): boolean { if (!this.capturing) return super.onKeyDown(event); event.preventDefault(); this.capturing = false; if (event.key === 'Escape') { this.setText(keyLabel(this.key)); return true; } if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) { this.capturing = true; return true; } this.key = event.key; this.setText(keyLabel(this.key)); this.onCapture(this.key); return true; } override onBlur(): void { super.onBlur(); if (this.capturing) { this.capturing = false; this.setText(keyLabel(this.key)); } } } /** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */ export function keyLabel(key: string): string { if (key === ' ') return 'Space'; if (key === '') return '—'; return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : ''); }