import type { PropertyValue } from '../../core/model/Properties'; import { kindLabel, kindMark, STRUCT_FIELD_KINDS, type StructFieldKind } from '../../core/properties/kinds'; import type { PropertyDef, StructDef, StructFieldDef } from '../../core/properties/PropertySchema'; import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, TextField, Theme, type Widget, } from '../../ui'; import { addValue, attachRowActions, createField, FIELD_INDENT, type FieldHandlers, fieldDef, type FieldRow, MARK_WIDTH, NAME_WIDTH, READ_ONLY, removeButton, type RowActions, structFieldValue, } from './fieldFactory'; import { addNote, type InspectorHost } from './host'; /** The menu every "add a kind" button opens: one entry per kind, named as the kind table names it. */ export function kindMenu(kinds: readonly K[], pick: (kind: K) => void): MenuItem[] { return kinds.map((kind) => ({ label: kindLabel(kind), onSelect: () => pick(kind) })); } /** Changing a declaration – offered only on the asset that declares it. */ export interface VariableEditing { rename: (next: string) => void; remove: () => void; renameField: (field: StructFieldDef, next: string) => void; removeField: (field: StructFieldDef) => void; addField: (kind: StructFieldKind) => void; } export interface VariableOptions { edit?: VariableEditing; /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ actions?: RowActions; } /** * One variable, drawn the same on its asset and on a placed object – minus `edit` there, since the * shape belongs to the asset. Adds its rows to the body itself and returns the first. */ export function buildVariable( host: InspectorHost, def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, options: VariableOptions = {}, ): FieldRow { const { edit, actions } = options; const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); const head = gridRow(0, markLabel(kindMark(def.kind)), name); let setValue: (next: PropertyValue) => void = () => {}; if (def.kind === 'struct') { head.add(new Spacer(), 1); if (edit) head.add(addFieldMenu(STRUCT_FIELD_KINDS, edit)); } else { setValue = addValue(head, createField(host, def, value, handlers)); } if (edit) head.add(removeButton(edit.remove)); const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); if (def.kind === 'struct') setValue = structFieldRows(host, def, value, handlers, edit); return { row: head, setValue: (next) => setValue(next), setModified: (modified) => { if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted'); refreshActions(); }, }; } /** One row per field, each with its own value; returns the setter that pushes a bundle into them. */ function structFieldRows( host: InspectorHost, def: StructDef, value: PropertyValue, handlers: FieldHandlers, edit: VariableEditing | undefined, ): (next: PropertyValue) => void { if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); const setters = new Map void>(); for (const field of def.fields) { const row = fieldRow(field, edit); const item = createField( host, fieldDef(field), structFieldValue(value, field), handlers.structFieldHandlers?.(field) ?? READ_ONLY, ); setters.set(field.key, addValue(row, item)); if (edit) row.add(removeButton(() => edit.removeField(field))); host.body.add(row); } return (next) => { for (const field of def.fields) setters.get(field.key)?.(structFieldValue(next, field)); }; } function fieldRow(field: StructFieldDef, edit: VariableEditing | undefined): HStack { const name = nameCell(field.key, 1, edit && ((next) => edit.renameField(field, next))); return gridRow(1, markLabel(kindMark(field.kind)), name); } /** Indent, mark and name – sized so that whatever follows starts on the value column at any depth. */ function gridRow(depth: number, mark: Widget, name: Widget): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; if (depth > 0) row.add(new Spacer(FIELD_INDENT)); row.add(mark); row.add(name); return row; } function nameWidth(depth: number): number { return NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap); } /** A text field where the name may be changed, else a label whose text sits where the field's would. */ function nameCell(text: string, depth: number, rename: ((next: string) => void) | undefined): Widget { const width = nameWidth(depth); return rename ? new TextField(text, { width, onChange: (next) => rename(next.trim()) }) : new Label(text, { style: 'muted', width, padding: Theme.fieldPadding }); } export function markLabel(glyph: string): Label { return new Label(glyph, { style: 'small', align: 'center', width: MARK_WIDTH }); } /** * A kind mark that can be clicked – the List tab's way of saying what an item holds. The label may * take the button's whole width: the column is sized for its widest glyph, and the usual inset * would cut `Aa` to `A…`. */ class MarkButton extends Button { protected override labelSlot(): number { return this.w; } } /** The mark of a kind, as a button that offers the others. */ export function markPicker(glyph: string, items: MenuItem[]): Button { const picker = new MarkButton(glyph, { variant: 'ghost', width: MARK_WIDTH, style: 'small' }); picker.onClick = () => new Menu(items).openBelow(picker); return picker; } function addFieldMenu(kinds: readonly StructFieldKind[], edit: VariableEditing): Dropdown { const add = new Dropdown('+ Field', { variant: 'ghost' }); add.onClick = () => new Menu(kindMenu(kinds, (kind) => edit.addField(kind))).openBelow(add); return add; }