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 { emptyField, type FieldKind, 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; } /** 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", …). */ 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[]; /** 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 }; } /** * 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, `extra` adds row-specific ones. Each group is asked 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, rowActions: RowActions = {}, ): 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 => { // 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 { 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 primitive as a property definition, so one widget set serves a list item and a struct field. */ export function primitiveDef(key: string, kind: FieldKind, label = key): PrimitiveDef { switch (kind) { case 'number': return { key, label, kind: 'number', default: 0 }; case 'text': return { key, label, kind: 'text', default: '' }; case 'boolean': return { key, label, kind: 'boolean', default: false }; case 'color': return { key, label, kind: 'color', default: 0 }; } } export type PrimitiveDef = Extract; /** 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) ?? emptyField(field.kind); } /** 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; if (!per) { const summary = new Label(textOf(value), { style: 'muted' }); return { widget: summary, grow: true, setValue: (v) => summary.setText(textOf(v)) }; } 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, primitiveDef(field.key, field.kind), structFieldValue(value, field), per(field), ); row.add(sub.widget, sub.grow ? 1 : 0); if (!sub.grow) row.add(new Spacer(), 1); 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': { // 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 = textOf(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. ...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 = textOf(v); button.setText(assetRefLabel(ctx.project(), current)); }, }; } case 'texture': { // Kept 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 = textOf(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('') }, ...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. new Menu(items, { searchable: true }).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => { current = textOf(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' : ''); }