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

+90 -241
View File
@@ -1,108 +1,46 @@
import { Container, type FederatedPointerEvent, Graphics, Sprite, Text } from 'pixi.js';
import { Container, Graphics, Sprite, Text } from 'pixi.js';
import type { StoredTexture } from '../../core/store/TextureStore';
import {
Button,
drawControlBox,
formatBytes,
HStack,
Label,
Popup,
pressCount,
ScrollContainer,
type Size,
Spacer,
Theme,
textStyle,
truncate,
Widget,
} from '../../ui';
import { formatBytes, type Size, textStyle, truncate } from '../../ui';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { importStoredImage, pickAndImport, type TextureImportDeps } from '../textureImport';
import {
currentPicker,
FilePicker,
openPicker,
PICKER_WIDTH,
PickerList,
type PickerItem,
type PickerRow,
withAssetNames,
} from '../FilePicker';
import { importStoredImage, pickAndImportImages, type TextureImportDeps } from '../textureImport';
import { CAPTION_HEIGHT, containRect, gridMetrics, type GridMetrics, tileAt, tileRect } from './imageGrid';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
const GRID_WIDTH = 560;
const GRID_HEIGHT = 340;
/** Inset of the picture inside its tile, so the selection frame stays visible around it. */
const TILE_PAD = 4;
/** One offered picture, with the asset that already wears it (if any). */
interface PickerItem extends StoredTexture {
assetName: string | null;
type ImageItem = PickerItem<StoredTexture>;
interface Tile extends PickerRow {
sprite: Sprite;
}
/**
* The tiles. Pictures come from the shared `textureLibrary`, which keys by file name, so a
* thumbnail of a picture the scene already draws costs no second upload to the GPU.
* Pictures come from the shared `textureLibrary`, which keys by file name, so a thumbnail of a
* picture the scene already draws costs no second upload to the GPU.
*/
class ImageGrid extends Widget {
private items: PickerItem[] = [];
private tiles: { box: Container; background: Graphics; sprite: Sprite; caption: Text }[] = [];
private metrics: GridMetrics = gridMetrics(GRID_WIDTH, 0);
private selectedFile: string | null = null;
private readonly empty = new Text({ text: '', style: textStyle('muted') });
private readonly stopWatching: () => void;
onPick?: (file: string) => void;
onActivate?: (file: string) => void;
emptyText = '';
class ImageGrid extends PickerList<ImageItem, Tile> {
private metrics: GridMetrics = gridMetrics(PICKER_WIDTH, 0);
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.stopWatching = textureLibrary.onChange(() => this.invalidate());
}
setItems(items: PickerItem[]): void {
this.items = items;
for (const tile of this.tiles) tile.box.destroy({ children: true });
this.tiles = items.map(() => {
// A Container holding the background, not a Graphics with children: Pixi v8 only lets
// containers have them (`addChild: Only Containers will be allowed to add children`).
const box = new Container();
const background = new Graphics();
const sprite = new Sprite();
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sprite, caption);
this.addChild(box);
return { box, background, sprite, caption };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
}
get selected(): PickerItem | 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 tiles themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.tiles.map((tile) => tile.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
this.redraw();
if (file) this.onPick?.(file);
this.watch(textureLibrary, () => this.invalidate());
}
/** Global centre of a tile; null when the file is not offered (test hook). */
tileGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const tile = this.tiles[index];
if (!tile) return null;
const rect = tileRect(index, this.metrics);
const p = tile.box.getGlobalPosition();
const found = this.rowOf(file);
if (!found) return null;
const rect = tileRect(found.index, this.metrics);
const p = found.row.box.getGlobalPosition();
return { x: p.x + rect.w / 2, y: p.y + this.metrics.tile / 2 };
}
@@ -115,190 +53,101 @@ class ImageGrid extends Widget {
override layout(): void {
this.metrics = gridMetrics(this.w, this.items.length);
this.tiles.forEach((tile, index) => {
this.rows.forEach((tile, index) => {
const rect = tileRect(index, this.metrics);
tile.box.position.set(rect.x, rect.y);
});
}
override redraw(): void {
const { tile: side } = this.metrics;
protected buildRow(): Tile {
const box = new Container();
const background = new Graphics();
const sprite = new Sprite();
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sprite, caption);
return { box, background, sprite, caption };
}
protected indexAt(x: number, y: number): number {
return tileAt(x, y, this.items.length, this.metrics);
}
protected redrawRow(tile: Tile, item: ImageItem, selected: boolean): void {
const side = this.metrics.tile;
const inner = side - TILE_PAD * 2;
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.drawBox(tile, side, side, selected);
this.tiles.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, side, side, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
const texture = item.width > 0 ? textureLibrary.load(item.file) : null;
tile.sprite.visible = texture !== null;
if (texture) {
const fitted = containRect(item.width, item.height, inner, inner);
tile.sprite.texture = texture;
tile.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y);
tile.sprite.width = fitted.w;
tile.sprite.height = fitted.h;
}
const texture = item.width > 0 ? textureLibrary.load(item.file) : null;
node.sprite.visible = texture !== null;
if (texture) {
const fitted = containRect(item.width, item.height, inner, inner);
node.sprite.texture = texture;
node.sprite.position.set(TILE_PAD + fitted.x, TILE_PAD + fitted.y);
node.sprite.width = fitted.w;
node.sprite.height = fitted.h;
}
// The asset name is the useful caption where there is one, then the name the file was
// uploaded under; a bare hash tells nobody anything, and the size is the last resort.
const text =
item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable';
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(text, node.caption.style, side);
node.caption.position.set(0, side + 1);
});
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = tileAt(local.x, local.y, this.items.length, this.metrics);
const item = this.items[index];
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
// The asset name is the useful caption where there is one, then the name the file was
// uploaded under; a bare hash tells nobody anything, and the size is the last resort.
const text =
item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable';
tile.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
tile.caption.text = truncate(text, tile.caption.style, side);
tile.caption.position.set(0, side + 1);
}
}
/**
* Offers every picture the server holds, rather than the machine's file system: uploading the
* same image twice is wasteful, and nothing collects the files a project stopped referencing.
* Uploading is still one button away, for a picture the server does not have yet.
*/
export class ImagePicker extends Popup {
private readonly grid = new ImageGrid();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class ImagePicker extends FilePicker<ImageItem> {
declare protected readonly list: ImageGrid;
constructor(private readonly deps: TextureImportDeps) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label('Choose an image', { style: 'bodyBold' }));
this.status = new Label('Loading images…', { style: 'muted' });
this.content.add(this.status);
this.grid.minWidth = GRID_WIDTH;
this.grid.emptyText = 'No images on the server yet – upload one.';
this.grid.onPick = () => this.refreshSelection();
this.grid.onActivate = () => this.confirm();
this.scroll.minWidth = GRID_WIDTH;
this.scroll.minHeight = GRID_HEIGHT;
this.scroll.setContent(this.grid);
this.content.add(this.scroll);
this.detail = new Label('', { style: 'muted' });
this.content.add(this.detail);
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
const upload = new Button('Upload…');
upload.onClick = this.closeThen(() => pickAndImport(this.deps));
buttons.add(upload);
buttons.add(new Spacer(), 1);
const cancel = new Button('Cancel');
cancel.onClick = this.closeThen();
buttons.add(cancel);
this.use = new Button('Use image', { variant: 'primary' });
this.use.onClick = () => this.confirm();
this.use.setEnabled(false);
buttons.add(this.use);
this.content.add(buttons);
void this.loadList();
}
/** 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.grid.fileList,
used: this.grid.usedFiles,
captions: this.grid.captions,
selected: this.grid.selected?.file ?? null,
};
super(
new ImageGrid(),
{
title: 'Choose an image',
loading: 'Loading images…',
empty: 'No images on the server yet – upload one.',
use: 'Use image',
noun: 'image',
},
() => pickAndImportImages(deps),
);
}
tileGlobal(file: string): { x: number; y: number } | null {
return this.grid.tileGlobal(file);
return this.list.tileGlobal(file);
}
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 grid's listener must be dropped exactly once.
if (!this.grid.destroyed) this.grid.destroy();
protected async fetchItems(): Promise<ImageItem[]> {
return withAssetNames(await this.deps.textures.list(), this.deps.state.project, 'texture');
}
private async loadList(): Promise<void> {
try {
const stored = await this.deps.textures.list();
const named = new Map<string, string>();
for (const asset of this.deps.state.project.assets) {
if (asset.type === 'texture') named.set(asset.file, asset.name);
}
this.grid.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
// Only as tall as the tiles need, up to the cap: a handful of pictures should not leave
// half a dialog of empty box under them.
const metrics = gridMetrics(GRID_WIDTH, stored.length);
this.scroll.minHeight = Math.min(
this.roomFor(this.scroll, metrics.tile + CAPTION_HEIGHT),
Math.max(metrics.tile + CAPTION_HEIGHT, metrics.height),
);
this.status.setText(
stored.length === 1 ? '1 image on the server' : `${stored.length} images on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the images: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
protected contentHeight(count: number): { unit: number; total: number } {
const metrics = gridMetrics(PICKER_WIDTH, count);
return { unit: metrics.tile + CAPTION_HEIGHT, total: metrics.height };
}
private refreshSelection(): void {
const item = this.grid.selected;
this.use.setEnabled(item !== null && item.width > 0);
this.detail.setText(item ? describe(item) : '');
this.refreshLayout();
/** A file the server could not read as an image has no size and cannot become an asset. */
protected override canUse(item: ImageItem): boolean {
return item.width > 0;
}
private confirm(): void {
const item = this.grid.selected;
if (!item || item.width <= 0) return;
this.close();
protected override describe(item: ImageItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
protected adopt(item: ImageItem): void {
importStoredImage(item, this.deps);
}
}
function describe(item: PickerItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
let current: ImagePicker | null = null;
export function openImagePicker(deps: TextureImportDeps): ImagePicker {
current?.close();
current = new ImagePicker(deps);
current.openCentered();
return current;
return openPicker(new ImagePicker(deps));
}
/** The open dialog, for the test hook. */
export function currentImagePicker(): ImagePicker | null {
return current;
return currentPicker(ImagePicker);
}