import type { Asset } from '../core/model/Asset'; import { findAsset } from '../core/model/Project'; import { Button, Label, 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`). * `saveView`/`restoreView` carry zoom and scroll across a switch to another document and back. */ export interface TabCanvas { readonly currentAssetId: string; show(assetId: string): void; fit?(): void; saveView?(): unknown; restoreView?(view: unknown): void; } export interface AssetTabOptions { /** Width reserved for the right-hand readout; a stack measures the label once, while empty. */ infoWidth: number; } /** * The Behavior, State machine, Texture, Container … tabs. One editor per kind serves every open * document of that kind (#171): the tab row tells it which one to show, and it keeps each one's * view for when it comes back. 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 { readonly fitButton: Button; protected readonly info: Label; /** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */ canvas!: C; private readonly views = new Map(); constructor( protected readonly state: EditorState, options: AssetTabOptions, ) { super(); 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` and wires the state events. */ protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void { this.canvas = canvas; const header = new TabHeader(); 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('project', () => this.refresh()); this.state.on('load', () => { this.views.clear(); this.canvas.show(''); this.refresh(); }); this.refresh(); } /** 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; } /** Shows a document, with the view it had when it was last on screen. */ showAsset(assetId: string): void { const from = this.canvas.currentAssetId; if (from !== assetId) { if (from) this.views.set(from, this.saveView()); this.canvas.show(assetId); const view = this.views.get(assetId); if (view !== undefined) this.restoreView(view); } this.refresh(); } /** A closed tab: opened again, the document starts from a fitted view. What it played stops. */ forgetView(assetId: string): void { this.views.delete(assetId); this.closed(assetId); } /** Stops what the tab plays of that asset; a tab that plays nothing has nothing to stop. */ protected closed(_assetId: string): void {} /** The selection's id for this kind of asset, `''` when the selection is about something else. */ abstract selectedId(): string; 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?.(); } /** The view to come back to; override when the tab has more than one. */ protected saveView(): unknown { return this.canvas.saveView?.(); } protected restoreView(view: unknown): void { this.canvas.restoreView?.(view); } /** The canvas's area on screen, without the header – so a check can ask what lies outside it. */ canvasRect(): { x: number; y: number; w: number; h: number } { const p = this.canvas.getGlobalPosition(); return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h }; } /** 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 refresh(): void { const asset = this.asset(); this.info.setText(asset ? this.infoText(asset) : ''); this.syncExtras(asset); } }