Three stores, three importers and three pickers that differ only in their type are one of each (#85)
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:
1 parent
15bdfdf07a
commit
cc6db5f350
42 files changed
+1375
-1650
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user