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

+64 -250
View File
@@ -1,296 +1,110 @@
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
import type { StoredFont } from '../../core/store/FontStore';
import {
Button,
drawControlBox,
formatBytes,
HStack,
Label,
Popup,
pressCount,
ScrollContainer,
type Size,
Spacer,
Theme,
textStyle,
truncate,
Widget,
} from '../../ui';
import { formatBytes, Theme, textStyle, truncate } from '../../ui';
import { fontLibrary } from '../../viewport/FontLibrary';
import {
currentPicker,
FilePicker,
openPicker,
type PickerItem,
type PickerRow,
RowList,
withAssetNames,
} from '../FilePicker';
import { type FontImportDeps, importStoredFont, pickAndImportFonts } from '../fontImport';
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
const LIST_WIDTH = 560;
const LIST_HEIGHT = 340;
const ROW_HEIGHT = 52;
const ROW_GAP = 4;
const ROW_PAD = 8;
/** Big enough to tell two typefaces apart at a glance, small enough for a long line to fit. */
const SAMPLE_SIZE = 22;
const SAMPLE_TEXT = 'Aa Bb Cc 0123 The quick brown fox';
/** One offered typeface, with the asset that already wears it (if any). */
interface PickerItem extends StoredFont {
assetName: string | null;
type FontItem = PickerItem<StoredFont>;
interface FontRow extends PickerRow {
sample: Text;
}
/**
* The rows. A typeface has no thumbnail to show, so each row *is* the preview: the sample line is
* drawn in the face itself, and reflows from the fallback once the library has loaded it.
* A typeface has no thumbnail to show, so each row *is* the preview: the sample line is drawn in
* the face itself, and reflows from the fallback once the library has loaded it.
*/
class FontList extends Widget {
private items: PickerItem[] = [];
private rows: { box: Container; background: Graphics; sample: Text; caption: Text }[] = [];
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 FontList extends RowList<FontItem, FontRow> {
protected readonly rowHeight = ROW_HEIGHT;
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.stopWatching = fontLibrary.onChange(() => this.redraw());
this.watch(fontLibrary, () => this.redraw());
}
setItems(items: PickerItem[]): void {
this.items = items;
for (const row of this.rows) row.box.destroy({ children: true });
this.rows = 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();
// Its own style object, never `textStyle('body')`: those are shared across the whole
// editor, so writing a family into one would restyle every label in the window.
const sample = new Text({
style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }),
});
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sample, caption);
this.addChild(box);
return { box, background, sample, caption };
});
if (!items.some((item) => item.file === this.selectedFile)) this.selectedFile = null;
this.layout();
this.redraw();
protected buildRow(): FontRow {
const box = new Container();
const background = new Graphics();
// Its own style object, never `textStyle('body')`: those are shared across the whole editor,
// so writing a family into one would restyle every label in the window.
const sample = new Text({ style: new TextStyle({ fontSize: SAMPLE_SIZE, fill: Theme.colors.text }) });
const caption = new Text({ style: textStyle('small') });
box.addChild(background, sample, caption);
return { box, background, sample, caption };
}
get selected(): PickerItem | null {
return this.items.find((item) => item.file === this.selectedFile) ?? null;
}
protected redrawRow(row: FontRow, item: FontItem, selected: boolean): void {
this.drawBox(row, this.w, ROW_HEIGHT, selected);
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
// library's change event brings it back here once the face has arrived.
row.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
row.sample.text = truncate(SAMPLE_TEXT, row.sample.style, this.w - ROW_PAD * 2);
row.sample.position.set(ROW_PAD, ROW_PAD - 2);
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);
}
/** Global centre of a row; null when the file is not offered (test hook). */
rowGlobal(file: string): { x: number; y: number } | null {
const index = this.items.findIndex((item) => item.file === file);
const row = this.rows[index];
if (!row) return null;
const p = row.box.getGlobalPosition();
return { x: p.x + this.w / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
}
override redraw(): void {
this.empty.text = this.items.length === 0 ? this.emptyText : '';
this.empty.position.set(0, 0);
this.rows.forEach((node, index) => {
const item = this.items[index];
if (!item) return;
const selected = item.file === this.selectedFile;
node.background.clear();
drawControlBox(node.background, this.w, ROW_HEIGHT, {
fill: selected ? Theme.colors.selection : Theme.colors.control,
border: selected ? Theme.colors.accent : Theme.colors.border,
});
// Empty family = not loaded yet; the row then reads in the editor's own typeface and the
// library's change event brings it back here once the face has arrived.
node.sample.style.fontFamily = fontLibrary.family(item.file) || Theme.font;
node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
node.sample.position.set(ROW_PAD, ROW_PAD - 2);
// The asset name wins where there is one – it is what the project calls this face. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(
item.assetName ?? (item.name || formatBytes(item.bytes)),
node.caption.style,
this.w - ROW_PAD * 2,
);
node.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
});
}
override destroy(): void {
this.stopWatching();
super.destroy({ children: true });
}
private onPress(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const index = Math.floor(local.y / (ROW_HEIGHT + ROW_GAP));
const item = local.x >= 0 && local.x <= this.w ? this.items[index] : undefined;
if (!item) return;
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
// The asset name wins where there is one – it is what the project calls this face. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
row.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
row.caption.text = truncate(
item.assetName ?? (item.name || formatBytes(item.bytes)),
row.caption.style,
this.w - ROW_PAD * 2,
);
row.caption.position.set(ROW_PAD, ROW_HEIGHT - 16);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every typeface the server holds, the way the image picker offers pictures: uploading the
* same font twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class FontPicker extends Popup {
private readonly list = new FontList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class FontPicker extends FilePicker<FontItem> {
declare protected readonly list: FontList;
constructor(private readonly deps: FontImportDeps) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label('Choose a font', { style: 'bodyBold' }));
this.status = new Label('Loading fonts…', { style: 'muted' });
this.content.add(this.status);
this.list.minWidth = LIST_WIDTH;
this.list.emptyText = 'No fonts on the server yet – upload one.';
this.list.onPick = () => this.refreshSelection();
this.list.onActivate = () => this.confirm();
this.scroll.minWidth = LIST_WIDTH;
this.scroll.minHeight = LIST_HEIGHT;
this.scroll.setContent(this.list);
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(() => pickAndImportFonts(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 font', { 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.list.fileList,
used: this.list.usedFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
};
super(
new FontList(),
{
title: 'Choose a font',
loading: 'Loading fonts…',
empty: 'No fonts on the server yet – upload one.',
use: 'Use font',
noun: 'font',
},
() => pickAndImportFonts(deps),
);
}
rowGlobal(file: string): { x: number; y: number } | null {
return this.list.rowGlobal(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 list's listener must be dropped exactly once.
if (!this.list.destroyed) this.list.destroy();
protected async fetchItems(): Promise<FontItem[]> {
return withAssetNames(await this.deps.fonts.list(), this.deps.state.project, 'font');
}
private async loadList(): Promise<void> {
try {
const stored = await this.deps.fonts.list();
const named = new Map<string, string>();
for (const asset of this.deps.state.project.assets) {
if (asset.type === 'font') named.set(asset.file, asset.name);
}
this.list.setItems(stored.map((item) => ({ ...item, assetName: named.get(item.file) ?? null })));
this.scroll.minHeight = Math.min(
this.roomFor(this.scroll, ROW_HEIGHT),
Math.max(ROW_HEIGHT, heightFor(stored.length)),
);
this.status.setText(
stored.length === 1 ? '1 font on the server' : `${stored.length} fonts on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the fonts: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
protected contentHeight(count: number): { unit: number; total: number } {
return { unit: ROW_HEIGHT, total: this.list.heightFor(count) };
}
private refreshSelection(): void {
const item = this.list.selected;
this.use.setEnabled(item !== null);
this.detail.setText(item ? describe(item) : '');
this.refreshLayout();
}
private confirm(): void {
const item = this.list.selected;
if (!item) return;
this.close();
protected adopt(item: FontItem): void {
importStoredFont(item, this.deps);
}
}
function describe(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;
}
let current: FontPicker | null = null;
export function openFontPicker(deps: FontImportDeps): FontPicker {
current?.close();
current = new FontPicker(deps);
current.openCentered();
return current;
return openPicker(new FontPicker(deps));
}
/** The open dialog, for the test hook. */
export function currentFontPicker(): FontPicker | null {
return current;
return currentPicker(FontPicker);
}