Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s

Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5.1 committed 2026-09-16 17:38:54 +02:00
1 parent 15bdfdf07a
commit cc6db5f350
42 files changed
+1375 -1650

No files matched your search

+338
View File
@@ -0,0 +1,338 @@
/**
* 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 extends StoredFile = StoredFile> = 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<S extends StoredFile>(
stored: S[],
project: Project,
type: AssetType,
): PickerItem<S>[] {
const named = new Map<string, string>();
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<Item extends PickerItem, Row extends PickerRow> 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<Item extends PickerItem, Row extends PickerRow> extends PickerList<Item, Row> {
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<PickerItem> | 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<P extends FilePicker<PickerItem>>(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<P extends FilePicker<PickerItem>>(
kind: abstract new (...args: never[]) => P,
): P | null {
return current instanceof kind ? current : null;
}
export abstract class FilePicker<Item extends PickerItem> extends Popup {
protected readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
constructor(
protected readonly list: PickerList<Item, PickerRow>,
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<void> {
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<Item[]>;
/** 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);
}
}