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

+71 -247
View File
@@ -1,247 +1,108 @@
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import type { StoredSound } from '../../core/store/SoundStore';
import {
Button,
drawControlBox,
formatBytes,
HStack,
Label,
Popup,
pressCount,
ScrollContainer,
type Size,
Spacer,
Theme,
textStyle,
truncate,
Widget,
} from '../../ui';
import { formatBytes, textStyle, truncate } from '../../ui';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
currentPicker,
FilePicker,
openPicker,
type PickerItem,
type PickerRow,
RowList,
withAssetNames,
} from '../FilePicker';
import { type SoundImportDeps, importStoredSound, pickAndImportSounds } from '../soundImport';
/** 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 = 44;
const ROW_GAP = 4;
const ROW_PAD = 8;
/** The play glyph's column at the left of a row: a press in here plays instead of selecting. */
const PLAY_WIDTH = 34;
const IDLE_HINT = 'Press ▶ to hear one.';
/** One offered sound, with the asset that already uses it (if any). */
interface PickerItem extends StoredSound {
assetName: string | null;
type SoundItem = PickerItem<StoredSound>;
interface SoundRow extends PickerRow {
play: Text;
detail: Text;
}
/**
* The rows. A sound has nothing to show, so each row shows what is known about the file and plays
* it on demand: the ▶ at its left is the preview, the rest of the row is the pick.
* A sound has nothing to show, so each row shows what is known about the file and plays it on
* demand: the ▶ at its left is the preview, the rest of the row is the pick.
*/
class SoundList extends Widget {
private items: PickerItem[] = [];
private rows: { box: Container; background: Graphics; play: Text; caption: Text; detail: Text }[] = [];
private selectedFile: string | null = null;
private readonly empty = new Text({ text: '', style: textStyle('muted') });
onPick?: (file: string) => void;
onActivate?: (file: string) => void;
emptyText = '';
class SoundList extends RowList<SoundItem, SoundRow> {
protected readonly rowHeight = ROW_HEIGHT;
/** The last file a ▶ started, so a check can tell a preview from a pick (test hook). */
lastPlayed: string | null = null;
constructor() {
super();
this.addChild(this.empty);
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
}
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();
const play = new Text({ text: '▶', style: textStyle('accent') });
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
this.addChild(box);
return { box, background, play, caption, detail };
});
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 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, clear of the ▶ column; 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 + PLAY_WIDTH + (this.w - PLAY_WIDTH) / 2, y: p.y + ROW_HEIGHT / 2 };
}
/** Global centre of a row's ▶ – what a check presses to hear one without choosing it. */
playGlobal(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();
const found = this.rowOf(file);
if (!found) return null;
const p = found.row.box.getGlobalPosition();
return { x: p.x + PLAY_WIDTH / 2, y: p.y + ROW_HEIGHT / 2 };
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, heightFor(this.items.length)) };
protected buildRow(): SoundRow {
const box = new Container();
const background = new Graphics();
const play = new Text({ text: '▶', style: textStyle('accent') });
const caption = new Text({ style: textStyle('body') });
const detail = new Text({ style: textStyle('small') });
box.addChild(background, play, caption, detail);
return { box, background, play, caption, detail };
}
override layout(): void {
this.rows.forEach((row, index) => row.box.position.set(0, index * (ROW_HEIGHT + ROW_GAP)));
protected redrawRow(row: SoundRow, item: SoundItem, selected: boolean): void {
this.drawBox(row, this.w, ROW_HEIGHT, selected);
row.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - row.play.height) / 2);
const width = this.w - PLAY_WIDTH - ROW_PAD;
row.caption.style = textStyle(item.assetName ? 'accent' : 'body');
row.caption.text = truncate(item.assetName ?? item.name, row.caption.style, width);
row.caption.position.set(PLAY_WIDTH, 6);
const detail =
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
row.detail.text = truncate(detail, row.detail.style, width);
row.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
}
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,
});
node.play.position.set(ROW_PAD + 2, (ROW_HEIGHT - node.play.height) / 2);
// The asset name wins where there is one – it is what the project calls this sound. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'body');
node.caption.text = truncate(
item.assetName ?? item.name,
node.caption.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.caption.position.set(PLAY_WIDTH, 6);
node.detail.text = truncate(
item.assetName && item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes),
node.detail.style,
this.w - PLAY_WIDTH - ROW_PAD,
);
node.detail.position.set(PLAY_WIDTH, ROW_HEIGHT - 16);
});
}
private onPress(e: FederatedPointerEvent): void {
protected override 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;
if (local.x < PLAY_WIDTH) {
const item = this.items[this.indexAt(local.x, local.y)];
if (item && local.x < PLAY_WIDTH) {
this.lastPlayed = item.file;
soundLibrary.play(item.file, 1, false);
return;
}
this.select(item.file);
if (pressCount(e, item.file) === 2) this.onActivate?.(item.file);
super.onPress(e);
}
}
function heightFor(count: number): number {
return count === 0 ? 0 : count * ROW_HEIGHT + (count - 1) * ROW_GAP;
}
/**
* Offers every sound the server holds, the way the font picker offers typefaces: uploading the same
* file twice is wasteful, and nothing collects the files a project stopped referencing.
*/
export class SoundPicker extends Popup {
private readonly list = new SoundList();
private readonly scroll = new ScrollContainer();
private readonly status: Label;
private readonly detail: Label;
private readonly use: Button;
export class SoundPicker extends FilePicker<SoundItem> {
declare protected readonly list: SoundList;
constructor(private readonly deps: SoundImportDeps) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
this.content.add(new Label('Choose a sound', { style: 'bodyBold' }));
this.status = new Label('Loading sounds…', { style: 'muted' });
this.content.add(this.status);
this.list.minWidth = LIST_WIDTH;
this.list.emptyText = 'No sounds 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('Press ▶ to hear one.', { 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(() => pickAndImportSounds(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 sound', { variant: 'primary' });
this.use.onClick = () => this.confirm();
this.use.setEnabled(false);
buttons.add(this.use);
this.content.add(buttons);
void this.loadList();
super(
new SoundList(),
{
title: 'Choose a sound',
loading: 'Loading sounds…',
idle: IDLE_HINT,
empty: 'No sounds on the server yet – upload one.',
use: 'Use sound',
noun: 'sound',
},
() => pickAndImportSounds(deps),
);
}
/** What the dialog offers, which of it the project already uses, and what is picked (test hook). */
debugInfo(): {
files: string[];
used: string[];
captions: string[];
selected: string | null;
played: string | null;
} {
return {
files: this.list.fileList,
used: this.list.usedFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
played: this.list.lastPlayed,
};
override debugInfo(): ReturnType<FilePicker<SoundItem>['debugInfo']> & { played: string | null } {
return { ...super.debugInfo(), played: this.list.lastPlayed };
}
rowGlobal(file: string): { x: number; y: number } | null {
return this.list.rowGlobal(file);
return this.list.rowGlobal(file, PLAY_WIDTH);
}
playGlobal(file: string): { x: number; y: number } | null {
@@ -250,64 +111,27 @@ export class SoundPicker extends Popup {
override close(): void {
super.close();
if (current === this) current = null;
// A preview left running would outlive the dialog that started it, with nothing left to stop it.
soundLibrary.stopAll();
}
private async loadList(): Promise<void> {
try {
const stored = await this.deps.sounds.list();
const named = new Map<string, string>();
for (const asset of this.deps.state.project.assets) {
if (asset.type === 'sound') 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 sound on the server' : `${stored.length} sounds on the server`,
);
} catch (error) {
this.status.setText(
`Could not list the sounds: ${error instanceof Error ? error.message : String(error)}`,
);
}
this.refreshSelection();
protected async fetchItems(): Promise<SoundItem[]> {
return withAssetNames(await this.deps.sounds.list(), this.deps.state.project, 'sound');
}
private refreshSelection(): void {
const item = this.list.selected;
this.use.setEnabled(item !== null);
this.detail.setText(item ? describe(item) : 'Press ▶ to hear one.');
this.refreshLayout();
protected contentHeight(count: number): { unit: number; total: number } {
return { unit: ROW_HEIGHT, total: this.list.heightFor(count) };
}
private confirm(): void {
const item = this.list.selected;
if (!item) return;
this.close();
protected adopt(item: SoundItem): void {
importStoredSound(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: SoundPicker | null = null;
export function openSoundPicker(deps: SoundImportDeps): SoundPicker {
current?.close();
current = new SoundPicker(deps);
current.openCentered();
return current;
return openPicker(new SoundPicker(deps));
}
/** The open dialog, for the test hook. */
export function currentSoundPicker(): SoundPicker | null {
return current;
return currentPicker(SoundPicker);
}