/** * The dialog behind "+ → Texture / Font / Sound": everything the server holds of one kind, with * what the project already calls it. Offering the server's files rather than the machine's is the * point – uploading the same file twice is wasteful, and nothing collects the files a project * stopped referencing. Uploading is still one button away. * * The list differs per kind (tiles for pictures, rows with a sample line for typefaces, rows with a * ▶ for sounds); the dialog around it does not. */ import { type Container, type FederatedPointerEvent, type Graphics, Text } from 'pixi.js'; import type { AssetType } from '../core/model/AssetTypes'; import type { Project } from '../core/model/Project'; import type { StoredFile } from '../core/store/FileStore'; import { Button, drawControlBox, formatBytes, HStack, Label, Popup, pressCount, ScrollContainer, Spacer, Theme, textStyle, Widget, } from '../ui'; /** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ export const PICKER_WIDTH = 560; export const PICKER_HEIGHT = 340; /** One offered file, with the asset that already wears it (if any). */ export type PickerItem = S & { assetName: string | null }; /** Pairs what the server lists with what the project calls it – the asset name is the caption worth showing. */ export function withAssetNames( stored: S[], project: Project, type: AssetType, ): PickerItem[] { const named = new Map(); for (const asset of project.assets) { if (asset.type === type && 'file' in asset) named.set(asset.file, asset.name); } return stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })); } /** The line under the list for a picked file: its upload name and size, and who wears it. */ export function describeStored(item: PickerItem): string { const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; } export interface PickerRow { /** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */ box: Container; background: Graphics; caption: Text; } /** * The selectable part of the dialog: one entry per file, one of them picked, a double press * confirms. How entries are built, placed and drawn is the subclass's. */ export abstract class PickerList extends Widget { protected items: Item[] = []; protected rows: Row[] = []; private selectedFile: string | null = null; private readonly empty = new Text({ text: '', style: textStyle('muted') }); private readonly unwatch: (() => void)[] = []; onPick?: (file: string) => void; onActivate?: (file: string) => void; emptyText = ''; constructor() { super(); this.addChild(this.empty); this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e)); } /** Redraws whenever a library reports an arrival, until the list is destroyed. */ protected watch(library: { onChange(fn: () => void): () => void }, onArrive: () => void): void { this.unwatch.push(library.onChange(onArrive)); } setItems(items: Item[]): void { this.items = items; for (const row of this.rows) row.box.destroy({ children: true }); this.rows = items.map(() => { const row = this.buildRow(); this.addChild(row.box); return row; }); if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null; this.layout(); this.redraw(); } get selected(): Item | null { return this.items.find((item) => item.file === this.selectedFile) ?? null; } get fileList(): string[] { return this.items.map((item) => item.file); } get usedFiles(): string[] { return this.items.filter((item) => item.assetName !== null).map((item) => item.file); } /** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */ get captions(): string[] { return this.rows.map((row) => row.caption.text); } select(file: string | null): void { if (file === this.selectedFile) return; this.selectedFile = file; this.redraw(); if (file) this.onPick?.(file); } /** The row of a file, with its index; null when the file is not offered. */ protected rowOf(file: string): { row: Row; index: number } | null { const index = this.items.findIndex((item) => item.file === file); const row = this.rows[index]; return row ? { row, index } : null; } override redraw(): void { this.empty.text = this.items.length === 0 ? this.emptyText : ''; this.empty.position.set(0, 0); this.rows.forEach((row, index) => { const item = this.items[index]; if (item) this.redrawRow(row, item, item.file === this.selectedFile); }); } override destroy(): void { for (const stop of this.unwatch) stop(); super.destroy({ children: true }); } protected drawBox(row: Row, w: number, h: number, selected: boolean): void { row.background.clear(); drawControlBox(row.background, w, h, { fill: selected ? Theme.colors.selection : Theme.colors.control, border: selected ? Theme.colors.accent : Theme.colors.border, }); } protected onPress(e: FederatedPointerEvent): void { const local = this.toLocal(e.global); const item = this.items[this.indexAt(local.x, local.y)]; if (!item) return; this.select(item.file); if (pressCount(e, item.file) === 2) this.onActivate?.(item.file); } protected abstract buildRow(): Row; /** Which entry a local point is on; any index without an item means none. */ protected abstract indexAt(x: number, y: number): number; protected abstract redrawRow(row: Row, item: Item, selected: boolean): void; } /** A list of equally tall rows, one under the other. */ export abstract class RowList extends PickerList { protected abstract readonly rowHeight: number; protected readonly rowGap = 4; /** Global centre of a row (test hook); `fromX` keeps clear of a column that does something else. */ rowGlobal(file: string, fromX = 0): { x: number; y: number } | null { const found = this.rowOf(file); if (!found) return null; const p = found.row.box.getGlobalPosition(); return { x: p.x + fromX + (this.w - fromX) / 2, y: p.y + this.rowHeight / 2 }; } heightFor(count: number): number { return count === 0 ? 0 : count * this.rowHeight + (count - 1) * this.rowGap; } override measure(): { w: number; h: number } { return { w: this.minWidth, h: Math.max(this.minHeight, this.heightFor(this.items.length)) }; } override layout(): void { this.rows.forEach((row, index) => row.box.position.set(0, index * (this.rowHeight + this.rowGap))); } protected override indexAt(x: number, y: number): number { return x >= 0 && x <= this.w ? Math.floor(y / (this.rowHeight + this.rowGap)) : -1; } } export interface PickerWords { title: string; loading: string; /** Under the list while nothing is picked; a hint for what the rows can do. */ idle?: string; empty: string; use: string; /** "image", "font", "sound" – for "3 fonts on the server". */ noun: string; } let current: FilePicker | null = null; /** * Opens the dialog centred, closing whichever picker was open before it, and starts the listing. * The listing starts here and not in the constructor: it asks the subclass, and a subclass's own * fields are not assigned until its constructor has run past `super()`. */ export function openPicker

>(picker: P): P { current?.close(); current = picker; picker.openCentered(); void picker.load(); return picker; } /** The open dialog if it is one of `kind`, for the test hooks. */ export function currentPicker

>( kind: abstract new (...args: never[]) => P, ): P | null { return current instanceof kind ? current : null; } export abstract class FilePicker extends Popup { protected readonly scroll = new ScrollContainer(); private readonly status: Label; private readonly detail: Label; private readonly use: Button; constructor( protected readonly list: PickerList, private readonly words: PickerWords, upload: () => void, ) { super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); this.content.add(new Label(words.title, { style: 'bodyBold' })); this.status = new Label(words.loading, { style: 'muted' }); this.content.add(this.status); list.minWidth = PICKER_WIDTH; list.emptyText = words.empty; list.onPick = () => this.refreshSelection(); list.onActivate = () => this.confirm(); this.scroll.minWidth = PICKER_WIDTH; this.scroll.minHeight = PICKER_HEIGHT; this.scroll.setContent(list); this.content.add(this.scroll); this.detail = new Label(words.idle ?? '', { style: 'muted' }); this.content.add(this.detail); const buttons = new HStack({ gap: Theme.gap, align: 'center' }); const uploadButton = new Button('Upload…'); uploadButton.onClick = this.closeThen(upload); buttons.add(uploadButton); buttons.add(new Spacer(), 1); const cancel = new Button('Cancel'); cancel.onClick = this.closeThen(); buttons.add(cancel); this.use = new Button(words.use, { variant: 'primary' }); this.use.onClick = () => this.confirm(); this.use.setEnabled(false); buttons.add(this.use); this.content.add(buttons); } /** What the dialog offers, which of it the project already wears, and what is picked (test hook). */ debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } { return { files: this.list.fileList, used: this.list.usedFiles, captions: this.list.captions, selected: this.list.selected?.file ?? null, }; } override close(): void { super.close(); if (current === this) current = null; // Escape, Cancel and a confirmed pick all land here, and `Popup.close` tolerates a second // call; the list's listeners must be dropped exactly once. if (!this.list.destroyed) this.list.destroy(); } async load(): Promise { try { const items = await this.fetchItems(); this.list.setItems(items); // Only as tall as the entries need, up to the cap: a handful of files should not leave half // a dialog of empty box under them. const { unit, total } = this.contentHeight(items.length); this.scroll.minHeight = Math.min(this.roomFor(this.scroll, unit), Math.max(unit, total)); const { noun } = this.words; this.status.setText( items.length === 1 ? `1 ${noun} on the server` : `${items.length} ${noun}s on the server`, ); } catch (error) { this.status.setText( `Could not list the ${this.words.noun}s: ${error instanceof Error ? error.message : String(error)}`, ); } this.refreshSelection(); } private refreshSelection(): void { const item = this.list.selected; this.use.setEnabled(item !== null && this.canUse(item)); this.detail.setText(item ? this.describe(item) : (this.words.idle ?? '')); this.refreshLayout(); } private confirm(): void { const item = this.list.selected; if (!item || !this.canUse(item)) return; this.close(); this.adopt(item); } /** The server's list, paired with the project's names (`withAssetNames`). */ protected abstract fetchItems(): Promise; /** How tall the list is for `count` entries, and the height of one – the least the scroll shows. */ protected abstract contentHeight(count: number): { unit: number; total: number }; protected abstract adopt(item: Item): void; protected canUse(_item: Item): boolean { return true; } protected describe(item: Item): string { return describeStored(item); } }