import type { PropertyValue } from '../../core/model/Properties'; import { type Kind, kindLabel, kindMark, NESTED_KINDS, type NestedKind } from '../../core/properties/kinds'; import type { PropertyDef, StructFieldDef } from '../../core/properties/PropertySchema'; import { Button, Dropdown, Grip, HStack, Icon, Label, Menu, type MenuEntry, type MenuItem, type SortBlock, sortBlocks, Spacer, TextField, Theme, type Widget, } from '../../ui'; import { addValue, attachRowActions, createField, FIELD_INDENT, type Field, type FieldHandlers, fieldDef, type FieldRow, indentedBy, MARK_WIDTH, nameWidth, READ_ONLY, removeButton, type RowActions, structFieldValue, } from './fieldFactory'; import { KIND_ICON_SVG, VARIABLE_SWITCH_ICON_SVG } from '../panels/markIcons'; import { addedRows, 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. `path` names the struct a * field belongs to, from the variable down (#239): none for the variable's own fields. */ export interface VariableEditing { rename: (next: string) => void; remove: () => void; renameField: (path: readonly string[], field: StructFieldDef, next: string) => void; removeField: (path: readonly string[], field: StructFieldDef) => void; addField: (path: readonly string[], kind: NestedKind) => void; /** A field dragged to another place among the fields of its struct (#310). */ moveField: (path: readonly string[], from: number, to: number) => void; } /** * A behavior's switch for one variable: carried on from one `Run behavior` call to the next (#229), * or hidden from everything outside its graph (#257). */ export interface VariableSwitch { on: boolean; set: (on: boolean) => void; } /** * The type of a struct (#253), offered where its declaration is edited. `path` names the struct * from the variable down, as for `VariableEditing`. */ export interface StructTyping { /** The Struct asset the struct follows, or null while it holds its own fields – a deleted type too. */ typeName: (structType: string | undefined) => string | null; menu: (path: readonly string[], structType: string | undefined) => MenuEntry[]; /** The item type of a list (#254); `path` leads to the list field, none for a list variable. */ listMenu: (path: readonly string[], itemType: string | undefined) => MenuEntry[]; } export interface VariableOptions { edit?: VariableEditing; typing?: StructTyping; keep?: VariableSwitch; hidden?: VariableSwitch; /** ↺/↑ on the first row: an override covers a whole struct, so its fields share them. */ actions?: RowActions; } /** A variable's first row, and its mark – where the asset lets it be dragged to another place (#310). */ export interface VariableRow extends FieldRow { grip: Grip; } /** * 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 = {}, ): VariableRow { const { edit, typing, keep, hidden, actions } = options; const name = nameCell(def.key, 0, edit && ((next) => edit.rename(next))); const grip = new Grip(markLabel(def.kind)); const head = gridRow(0, grip, name); let setValue: (next: PropertyValue) => void = () => {}; let below: Widget | undefined; // A struct with a type has the type's fields: they are changed on the Struct asset, not here. const typed = def.kind === 'struct' ? (typing?.typeName(def.structType) ?? null) : null; // A private value is set in its own graph only (#257): shown, but not edited here. const locked = hidden?.on ?? false; if (def.kind === 'struct') { head.add(new Spacer(), 1); if (edit && typed === null) head.add(addFieldMenu(edit, [])); } else { const field = createField(host, def, value, handlers); setValue = addValue(head, field); below = field.below; lockValue(field, locked); } if (hidden) head.add(switchButton('private', hidden)); if (keep) head.add(switchButton('keep', keep)); if (edit) head.add(removeButton(edit.remove)); const refreshActions = actions ? attachRowActions(head, actions) : () => {}; host.body.add(head); if (def.kind === 'list' && typing) host.body.add(itemsRow(1, typing.typeName(def.itemType), () => typing.listMenu([], def.itemType))); if (below) host.body.add(indentedBy(below, 1)); if (def.kind === 'struct') { if (typing) host.body.add(typeRow(1, typed, () => typing.menu([], def.structType))); if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); const own = typed === null; setValue = structFieldRows( host, def.fields, [], value, handlers, own ? edit : undefined, own ? typing : undefined, locked, ); } return { row: head, grip, setValue: (next) => setValue(next), setModified: (modified) => { if (name instanceof Label) name.setStyle(modified ? 'override' : 'muted'); refreshActions(); }, }; } const SWITCH_TIPS: Record = { keep: 'Keep the value between calls', private: 'Private: hidden outside this graph', }; /** Lit while the switch is on – the lock while private, the pin while kept; a click flips it. */ function switchButton(icon: keyof typeof VARIABLE_SWITCH_ICON_SVG, of: VariableSwitch): Button { const button = new Button('', { variant: 'ghost', toggle: true, icon: VARIABLE_SWITCH_ICON_SVG[icon], padding: 4, tooltip: SWITCH_TIPS[icon], onClick: () => of.set(!of.on), }); button.setToggled(of.on); return button; } /** A value – and the items below it – greyed out and deaf to the pointer while `locked`. */ function lockValue(field: Field, locked: boolean): void { if (!locked) return; field.widget.setEnabled(false); field.below?.setEnabled(false); } /** * One row per field, each with its own value; a field that is a struct again is a head row with its * fields indented below it (#239). `path` names the struct these fields belong to. Returns the * setter that pushes a bundle into them. Where the fields are edited, each one – its own fields * and items included – is dragged by its mark among its siblings (#310). */ function structFieldRows( host: InspectorHost, fields: readonly StructFieldDef[], path: readonly string[], value: PropertyValue, handlers: FieldHandlers, edit: VariableEditing | undefined, typing: StructTyping | undefined, locked: boolean, ): (next: PropertyValue) => void { const setters = new Map void>(); const blocks: SortBlock[] = []; for (const field of fields) { const { row, mark } = fieldRow(field, path, edit); const item = structFieldValue(value, field); const { rows } = addedRows(host, () => { if (field.kind === 'struct') { const inner = [...path, field.key]; const typed = typing?.typeName(field.structType) ?? null; row.add(new Spacer(), 1); if (edit && typed === null) row.add(addFieldMenu(edit, inner)); if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); if (typing) host.body.add(typeRow(inner.length + 1, typed, () => typing.menu(inner, field.structType))); const own = typed === null; setters.set( field.key, structFieldRows( host, field.fields, inner, item, handlers, own ? edit : undefined, own ? typing : undefined, locked, ), ); return; } const own = handlers.structFieldHandlers?.([...path, field.key], field) ?? READ_ONLY; const sub = createField(host, fieldDef(field), item, own); setters.set(field.key, addValue(row, sub)); lockValue(sub, locked); if (edit) row.add(removeButton(() => edit.removeField(path, field))); host.body.add(row); if (field.kind === 'list' && typing) { const inner = [...path, field.key]; host.body.add( itemsRow(path.length + 2, typing.typeName(field.itemType), () => typing.listMenu(inner, field.itemType), ), ); } if (sub.below) host.body.add(indentedBy(sub.below, path.length + 2)); }); blocks.push({ grip: mark, rows }); } if (edit) sortBlocks(blocks, (from, to) => edit.moveField(path, from, to)); return (next) => { for (const field of fields) setters.get(field.key)?.(structFieldValue(next, field)); }; } function fieldRow( field: StructFieldDef, path: readonly string[], edit: VariableEditing | undefined, ): { row: HStack; mark: Grip } { const depth = path.length + 1; const name = nameCell(field.key, depth, edit && ((next) => edit.renameField(path, field, next))); const mark = new Grip(markLabel(field.kind)); return { row: gridRow(depth, mark, name), mark }; } /** Which Struct asset a struct follows, as a row of its own under the struct's head. */ function typeRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack { return pickerRow(depth, 'Type', typeName ?? 'Own fields', menu); } /** Which Struct asset every item of a list is (#254), as a row of its own under the list's head. */ function itemsRow(depth: number, typeName: string | null, menu: () => MenuEntry[]): HStack { return pickerRow(depth, 'Items', typeName ?? 'Mixed', menu); } function pickerRow(depth: number, label: string, text: string, menu: () => MenuEntry[]): HStack { const picker = new Dropdown(text, { variant: 'ghost' }); picker.onClick = () => new Menu(menu()).openBelow(picker); const name = new Label(label, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding }); const row = gridRow(depth, new Spacer(MARK_WIDTH), name); row.add(picker, 1); return row; } /** 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(depth * (FIELD_INDENT + Theme.gap) - Theme.gap)); row.add(mark); row.add(name); return row; } /** 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 }); } /** A kind's mark: its picture, or its letters for the kinds that have no picture (`#`, `Aa`). */ export function markLabel(kind: string): Widget { const icon = KIND_ICON_SVG[kind as Kind]; return icon ? new Icon(icon, MARK_WIDTH) : new Label(kindMark(kind), { style: 'small', align: 'center', width: MARK_WIDTH }); } /** * A kind mark that can be clicked – a list's way of saying what an item holds. The label may * take the button's whole width: the column is sized for its widest mark, 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(kind: string, items: MenuItem[]): Button { const icon = KIND_ICON_SVG[kind as Kind]; const picker = new MarkButton(icon ? '' : kindMark(kind), { variant: 'ghost', width: MARK_WIDTH, style: 'small', icon, tooltip: 'Change the kind', }); picker.onClick = () => new Menu(items).openBelow(picker); return picker; } /** `path` names the struct the field goes into: none for the variable's own fields. */ function addFieldMenu(edit: VariableEditing, path: readonly string[]): Dropdown { const add = new Dropdown('+ Field', { variant: 'ghost' }); add.onClick = () => new Menu(kindMenu(NESTED_KINDS, (kind) => edit.addField(path, kind))).openBelow(add); return add; }