/** * The Variables tab (#252): every variable of a behavior, a Variables asset or a placed behavior, * with the very rows the inspector draws for them – or one list, as deep inside them as it sits. * One editor for every such tab, pointed at the target of the one showing, like the asset editors. */ import type { Container } from 'pixi.js'; import { isVariableOwner } from '../../core/model/Asset'; import { findObject } from '../../core/model/hierarchy'; import { findAsset } from '../../core/model/Project'; import { objectName } from '../../core/model/SceneObject'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; import type { InspectorHost } from '../inspector/host'; import { viewOf } from '../inspector/objectQueries'; import { buildOwnRows, objectSignature, pickableObjects, refreshObjectSection, } from '../inspector/objectSection'; import { InlineList, ListHead } from '../list/InlineList'; import { type LiveVariables, resolveList } from '../list/slot'; import { objectPicker } from '../objectPick'; import type { EditorState, ValuesTarget } from '../state'; /** The asset or placed object whose variables `target` shows, when it still has any to show. */ function ownerOf(state: EditorState, target: Extract) { const { owner } = target; if (owner.kind === 'asset') { const asset = findAsset(state.project, owner.assetId); return isVariableOwner(asset) ? { asset, name: asset.name } : null; } const view = viewOf(state.project, owner.holderId); const object = view && findObject(view, owner.objectId); const asset = object && findAsset(state.project, object.assetId); return object && asset?.type === 'behavior' ? { asset, name: objectName(object, asset) } : null; } /** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */ export function valuesTitle(state: EditorState, target: ValuesTarget, live?: LiveVariables): string | null { if (target.kind === 'list') return resolveList(state, target.slot, live)?.title ?? null; const owner = ownerOf(state, target); if (!owner) return null; return `${owner.name} · ${owner.asset.type === 'struct' ? 'Fields' : 'Variables'}`; } export class VariablesEditor extends VStack { private readonly title = new Label('', { style: 'bodyBold' }); private readonly scroll = new ScrollContainer(); private host: InspectorHost; private target: ValuesTarget | null = null; private signature = ''; private lastLiveRefresh = 0; private rebuildWhenLeft = false; private readonly releaseFocusHandler: () => void; /** The list a list target draws, and its head. */ private list: { head: ListHead; items: InlineList } | null = null; /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ liveVariables: LiveVariables = () => null; constructor(private readonly state: EditorState) { super({ gap: 0 }); const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); header.minHeight = Theme.controlHeight + 2 * Theme.padding; header.add(this.title, 1); this.add(header); this.host = this.emptyHost(); this.scroll.setContent(this.host.body); this.add(this.scroll, 1); state.on('project', () => this.refresh()); state.on('runtime', () => this.refreshLive()); this.releaseFocusHandler = ui().focus.onFocusChange(() => { if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild(); }); } /** Signing out destroys the tab while the page stays up (#177), so the handler goes with it. */ override destroy(options?: Parameters[0]): void { this.releaseFocusHandler(); super.destroy(options); } /** Points the tab at another target; the one it showed keeps nothing, every edit is in the model. */ show(target: ValuesTarget): void { this.target = target; this.rebuild(); } /** For the check scripts: what the tab shows, and the lists drawn in it. */ debugInfo(): { title: string; target: ValuesTarget | null; rows: string[] } { return { title: this.title.text, target: this.target, rows: [...this.host.fields.keys()], }; } /** Every list drawn in the tab, at any depth. */ lists(): InlineList[] { return inlineListsIn(this.host.body); } private emptyHost(): InspectorHost { return { state: this.state, project: () => this.state.project, liveVariables: (assetId) => this.liveVariables(assetId), body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null, }; } private currentSignature(): string { const target = this.target; if (!target) return ''; if (target.kind === 'list') return resolveList(this.state, target.slot) ? 'list' : 'missing'; const { owner } = target; if (!ownerOf(this.state, target)) return 'missing'; return owner.kind === 'asset' ? assetSignature(this.state.project, owner.assetId) : objectSignature(this.state, owner.holderId, [owner.objectId], owner.objectId); } private rebuild(): void { this.rebuildWhenLeft = false; this.host.body.destroy({ children: true }); this.host = this.emptyHost(); this.list = null; this.signature = this.currentSignature(); const target = this.target; this.title.setText((target && valuesTitle(this.state, target, this.liveVariables)) ?? ''); if (target?.kind === 'list') this.buildList(target.slot); else if (target) { const owner = ownerOf(this.state, target); if (owner && target.owner.kind === 'asset') buildVariables(this.host, owner.asset, true); else if (target.owner.kind === 'object') buildOwnRows(this.host, target.owner.holderId, target.owner.objectId, true); } this.scroll.setContent(this.host.body); } private buildList(slot: Extract['slot']): void { const access = () => resolveList(this.state, slot, this.liveVariables); if (slot.kind === 'object') { this.host.objectChoices = () => pickableObjects(this.state.project, slot.holderId, slot.objectId); this.host.objectPicker = objectPicker( this.state, slot.holderId, slot.objectId, this.host.objectChoices, ); } const items = new InlineList(this.host, access); const head = new ListHead(access, (kind) => items.addItem(kind), false); items.head = head; this.host.body.add(head); this.host.body.add(items); this.list = { head, items }; } /** Play mode: follow what the behaviors write, ~10× a second, but never under a field being typed in. */ private refreshLive(): void { if (!this.visible) return; const now = performance.now(); if (now - this.lastLiveRefresh < 100) return; if (ui().focus.isWithin(this)) return; this.lastLiveRefresh = now; this.refresh(); } private refresh(): void { const target = this.target; if (!target) return; this.title.setText(valuesTitle(this.state, target, this.liveVariables) ?? ''); if (this.currentSignature() !== this.signature) { if (ui().focus.isWithin(this)) { this.rebuildWhenLeft = true; return; } this.rebuild(); return; } if (target.kind === 'list') { this.list?.head.setCount(this.list.items.access()?.value.length ?? 0); this.list?.items.refresh(); return; } const { owner } = target; const ok = owner.kind === 'asset' ? refreshAssetSection(this.host, owner.assetId) : refreshObjectSection(this.host, owner.holderId, owner.objectId); if (!ok) this.rebuild(); } } /** Every inline list below `root`, outermost first – what the check scripts pick one from. */ export function inlineListsIn(root: Container): InlineList[] { const found: InlineList[] = []; const walk = (node: Container): void => { if (node instanceof InlineList) found.push(node); for (const child of node.children) walk(child); }; walk(root); return found; }