import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand, } from '../../core/commands/assetCommands'; import { nextFieldName, nextVariableName } from '../../core/commands/naming'; import { AddStructFieldCommand, AddVariableCommand, RemoveStructFieldCommand, RemoveVariableCommand, RenameStructFieldCommand, RenameVariableCommand, } from '../../core/commands/variableCommands'; import type { Asset, BehaviorAsset } from '../../core/model/Asset'; import type { PropertyValue } from '../../core/model/Properties'; import { findAsset, type Project } from '../../core/model/Project'; import { LIST_ITEM_KINDS, type ListItemKind, type PropertyDef, schemaForAsset, type StructDef, VARIABLE_KINDS, type VariableKind, } from '../../core/properties/PropertySchema'; import { markedAssetSchema } from '../../core/properties/deviation'; import { isModified, resolveAssetProps } from '../../core/properties/resolve'; import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui'; import { aspectActions, sizeCommand } from './aspectAction'; import { createField, createFieldRow, type FieldHandlers, labelledRow, primitiveDef, structFieldValue, } from './fieldFactory'; import { addNote, addTitle, type InspectorHost } from './host'; import { listOpener, type PreviewedValue, previewingHandlers, structFields, structFieldsOf, } from './previewHandlers'; const VARIABLE_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text', list: 'List', struct: 'Struct', }; // Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`, but only the // VariableKinds ever reach it – the '?' fallback is a safety net, not a case to fill in. const VARIABLE_KIND_MARKS: Partial> = { number: '#', boolean: '✓', text: 'Aa', list: '≡', struct: '⊞', }; const FIELD_KIND_LABELS: Record = { number: 'Number', boolean: 'Boolean', text: 'Text', color: 'Color', }; const FIELD_INDENT = 12; const FIELD_KIND_MARKS: Record = { number: '#', boolean: '✓', text: 'Aa', color: '◧', }; /** * Structure-defining facts of an asset selection: the schema keys and kinds, plus the fields of a * struct – those are rows of their own, so adding one has to rebuild the section. */ export function assetSignature(project: Project, assetId: string): string { const asset = findAsset(project, assetId); return asset ? schemaForAsset(asset).map(defSignature).join(',') : 'missing'; } export function defSignature(def: PropertyDef): string { const fields = def.kind === 'struct' ? def.fields.map((f) => `${f.key}:${f.kind}`).join('+') : ''; return `${def.key}:${def.kind}${fields ? `(${fields})` : ''}`; } /** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */ export function buildAssetSection(host: InspectorHost, assetId: string): void { const asset = findAsset(host.state.project, assetId); if (!asset) return; addTitle(host, `${asset.type.toUpperCase()} ASSET`); host.nameField = new TextField(asset.name, { onChange: (name) => { if (name.trim()) host.state.execute(new RenameAssetCommand(assetId, name.trim())); else host.nameField?.setValue(asset.name); }, }); const { row } = labelledRow('Name'); row.add(host.nameField, 1); host.body.add(row); buildProps(host, asset, markedAssetSchema(asset)); if (asset.type === 'behavior') buildVariables(host, asset); } /** Pushes the current asset values into the rows. False when the asset is gone (rebuild). */ export function refreshAssetSection(host: InspectorHost, assetId: string): boolean { const asset = findAsset(host.state.project, assetId); if (!asset) return false; host.nameField?.setValue(asset.name); const values = resolveAssetProps(asset); for (const [key, row] of host.fields) { row.setValue(values[key] ?? 0); row.setModified(isModified(asset, key)); } return true; } function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void { const values = resolveAssetProps(asset); for (const def of schema) { const row = createFieldRow( host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), { extra: aspectActions( host, asset, def, () => { const target = findAsset(host.state.project, asset.id); return target ? resolveAssetProps(target) : null; }, (values) => host.state.execute( sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)), ), ), reset: () => { const target = findAsset(host.state.project, asset.id); if (!target || !isModified(target, def.key)) return []; return [ { label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), }, ]; }, }, ); row.setModified(isModified(asset, def.key)); host.fields.set(def.key, row); host.body.add(row.row); } } /** Where one property of an asset lives. Shared, so a whole row and a single struct field agree on it. */ function assetSpec(host: InspectorHost, assetId: string, key: string): PreviewedValue { return { target: () => findAsset(host.state.project, assetId), read: (asset) => asset.props[key], write: (asset, value) => (asset.props[key] = value), // No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own. commit: (value) => new SetAssetPropertyCommand(assetId, key, value), }; } /** Previews straight into the asset while editing; the command records the value from before the preview. */ function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers { const spec = assetSpec(host, assetId, def.key); return { ...previewingHandlers(host, spec), ...listOpener(host.state, def, () => ({ kind: 'asset', assetId, key: def.key })), ...structFields(host, def, spec), }; } /** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { const header = new HStack({ gap: Theme.gap, align: 'center' }); header.add(new Label('VARIABLES', { style: 'title' }), 1); const add = new Dropdown('+ Variable', { variant: 'ghost' }); add.onClick = () => { const items = VARIABLE_KINDS.map((kind) => ({ label: VARIABLE_KIND_LABELS[kind], onSelect: () => { const current = findAsset(host.state.project, asset.id); if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind)); }, })); new Menu(items).openBelow(add); }; header.add(add); host.body.add(header); if (asset.variables.length === 0) addNote(host, 'No variables yet.'); const values = resolveAssetProps(asset); for (const def of asset.variables) { if (def.kind === 'struct') { buildStructVariable(host, asset, def, values[def.key] ?? def.default); continue; } const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' }); mark.minWidth = 18; row.add(mark); row.add( new TextField(def.key, { width: 90, onChange: (next) => { host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())); }, }), ); const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def)); row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); row.add( new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)), }), ); host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} }); host.body.add(row); } } /** * A struct variable is a block rather than a row: the head renames it and adds fields, each field * below carries its own name, its value and a remove. That is the port editor `Pack` already has, * one level down and with a value column – a field is declared and given a default in one place. */ function buildStructVariable( host: InspectorHost, asset: BehaviorAsset, def: StructDef, value: PropertyValue, ): void { const head = new HStack({ gap: Theme.gap, align: 'center' }); head.minHeight = Theme.rowHeight; const mark = new Label(VARIABLE_KIND_MARKS.struct ?? '?', { style: 'small', align: 'center' }); mark.minWidth = 18; head.add(mark); head.add( new TextField(def.key, { width: 90, onChange: (next) => host.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim())), }), ); head.add(new Spacer(), 1); const add = new Dropdown('+ Field', { variant: 'ghost' }); add.onClick = () => { const items = LIST_ITEM_KINDS.map((kind) => ({ label: FIELD_KIND_LABELS[kind], onSelect: () => host.state.execute( new AddStructFieldCommand(asset.id, def.key, { key: nextFieldName(def.fields), kind }), ), })); new Menu(items).openBelow(add); }; head.add(add); head.add( new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)), }), ); host.body.add(head); if (def.fields.length === 0) addNote(host, 'No fields yet.', 'small'); const perField = structFieldsOf(host, def, assetSpec(host, asset.id, def.key)); const fields = new Map void>(); for (const field of def.fields) { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(new Spacer(FIELD_INDENT)); const fieldMark = new Label(FIELD_KIND_MARKS[field.kind], { style: 'small', align: 'center' }); fieldMark.minWidth = 18; row.add(fieldMark); row.add( new TextField(field.key, { width: 90, onChange: (next) => host.state.execute(new RenameStructFieldCommand(asset.id, def.key, field.key, next.trim())), }), ); const item = createField( host, primitiveDef(field.key, field.kind), structFieldValue(value, field), perField(field), ); row.add(item.widget, item.grow ? 1 : 0); if (!item.grow) row.add(new Spacer(), 1); row.add( new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveStructFieldCommand(asset.id, def.key, field.key)), }), ); fields.set(field.key, item.setValue); host.body.add(row); } host.fields.set(def.key, { row: head, setValue: (next) => { for (const field of def.fields) fields.get(field.key)?.(structFieldValue(next, field)); }, setModified: () => {}, }); }