import type { Asset } from '../core/model/Asset'; import { findAsset } from '../core/model/Project'; import { Button, Dropdown, Label, Menu, type MenuItem, Spacer, VStack, type Widget } from '../ui'; import type { EditorState } from './state'; import { TabHeader } from './TabHeader'; /** * The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. * A canvas without `fit` belongs to a tab whose Fit frames something else (see `AssetTabEditor.fit`). */ export interface TabCanvas { readonly currentAssetId: string; show(assetId: string): void; fit?(): void; } export interface AssetTabOptions { /** Shown left of the picker, and in the empty state as "No ". */ noun: string; /** Width reserved for the right-hand readout; a stack measures the label once, while empty. */ infoWidth: number; } /** * The Behavior, State machine, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere. * A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them. */ export abstract class AssetTabEditor< A extends Asset, C extends TabCanvas & Widget = TabCanvas & Widget, > extends VStack { /** Public for the check scripts. */ readonly picker: Dropdown; readonly fitButton: Button; protected readonly info: Label; /** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */ canvas!: C; constructor( protected readonly state: EditorState, private readonly options: AssetTabOptions, ) { super(); this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); this.fitButton = new Button('Fit', { onClick: () => this.fit() }); this.info = new Label('', { style: 'small', align: 'right' }); this.info.minWidth = options.infoWidth; } /** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */ protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void { this.canvas = canvas; const header = new TabHeader(); header.add(new Label(this.options.noun, { style: 'muted' })); header.add(this.picker); for (const widget of extras) header.add(widget); header.add(this.fitButton); header.add(new Spacer(), 1); header.add(this.info); this.add(header); if (strip) this.add(strip); this.add(this.body(canvas), 1); this.state.on('selection', () => this.followSelection()); this.state.on('project', () => this.refresh()); this.state.on('load', () => { this.canvas.show(''); this.followSelection(); }); this.followSelection(); } /** The asset currently shown, `''` for none – the check scripts read this. */ get currentAssetId(): string { return this.canvas.currentAssetId; } /** The right-hand readout as shown – the check scripts read this. */ get shownInfo(): string { return this.info.text; } /** The selection's id for this kind of asset, `''` when the selection is about something else. */ protected abstract selectedId(): string; protected abstract isMine(asset: Asset): asset is A; /** The right-hand readout; `null` is the empty state. */ protected abstract infoText(asset: A | null): string; /** What fills the tab below the header. Override to put something beside the canvas. */ protected body(canvas: C): Widget { return canvas; } /** What the header's Fit button frames. Override when the tab has more than one view. */ protected fit(): void { this.canvas.fit?.(); } /** Enable/disable whatever `extras` the subclass passed to `mount`. */ protected syncExtras(_asset: A | null): void {} protected asset(): A | null { const asset = findAsset(this.state.project, this.canvas.currentAssetId); return asset && this.isMine(asset) ? asset : null; } protected candidates(): A[] { return this.state.project.assets .filter((a): a is A => this.isMine(a)) .sort((a, b) => a.name.localeCompare(b.name)); } protected followSelection(): void { const id = this.selectedId(); // An empty id merely means "the selection is about something else"; the tab keeps its subject. if (id) this.canvas.show(id); this.refresh(); } protected refresh(): void { // A deleted asset leaves the canvas empty; fall back to whatever is left of this kind. if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? ''); const asset = this.asset(); this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`); this.info.setText(asset ? this.infoText(asset) : ''); this.syncExtras(asset); } private openPicker(): void { const current = this.canvas.currentAssetId; const items: MenuItem[] = this.candidates().map((asset) => ({ label: asset.id === current ? `● ${asset.name}` : asset.name, onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), })); if (items.length === 0) items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined }); new Menu(items, { searchable: true }).openBelow(this.picker); } }