Tabs are open documents: Scene, Game and Plugins stay as fixed tabs, every other asset opens in its own tab (double click or Enter in the asset panel, on creation, from the list button), several assets of one kind can be open at once, a tab is labelled with its kind dimmed before the name, cut at 160 px and renamed with its asset, closes with its x or a middle click and goes away when its asset is deleted; a tab always shows its own document instead of following the selection, the picker in the tab head is gone, zoom and scroll are kept per document for the session and dropped on close, a double click on a call node stays in the same tab with breadcrumbs per tab; one editor per kind is switched between documents, the placement rules live in the pure module documents.ts, and the test hook showTab stays as a compatibility path; new e2e module 44 documents (#171)
game-maker / gate (push) Successful in 1m59s
game-maker / suite (push) Successful in 6m53s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 15:49:54 +02:00
1 parent 4f546c0e08
commit cc285af10a
33 files changed
+895 -225

No files matched your search

+179 -39
View File
@@ -1,4 +1,5 @@
import type { Application } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import type { AssetType } from '../core/model/AssetTypes';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
@@ -20,6 +21,8 @@ import {
type Widget,
} from '../ui';
import { TabStrip, type TabPlace } from './TabStrip';
import type { AssetTabEditor } from './AssetTabEditor';
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
import { ConsolePanel } from './console/ConsolePanel';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
@@ -29,6 +32,7 @@ import { EditorRun } from './EditorRun';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { ListEditor } from './list/ListEditor';
import { resolveList } from './list/slot';
import { AnimationEditor } from './animation/AnimationEditor';
import { TileEditor } from './tile/TileEditor';
import { AssetPanel } from './panels/AssetPanel';
@@ -64,9 +68,20 @@ export type CentreTab =
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
export type PluginTabId = `plugin:${string}`;
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
export type TabId = CentreTab | PluginTabId;
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
export type DocumentId = `asset:${string}` | 'list';
/** A button in the tab row. */
export type StripTab = FixedTab | PluginTabId | DocumentId;
const TAB_ORDER = [
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'list'>;
const FIXED_TABS = ['scene', 'game', 'plugins'] as const;
const PANE_ORDER = [
'scene',
'game',
'graph',
@@ -81,6 +96,7 @@ const TAB_ORDER = [
'plugins',
] as const;
/** The fixed tabs' names, and the kind shown in front of a document's. */
const TAB_LABELS: Record<CentreTab, string> = {
scene: 'Scene',
game: 'Game',
@@ -109,6 +125,10 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
container: 'container',
};
function isDocument(tab: StripTab): tab is DocumentId {
return tab === 'list' || tab.startsWith('asset:');
}
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
@@ -163,8 +183,10 @@ export class Editor implements PlayHost {
readonly columnSplitter = new Splitter('y');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabs: TabStrip<TabId>;
private readonly tabs: TabStrip<StripTab>;
private readonly panes: Record<CentreTab, Widget>;
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
private documents: DocumentRow<DocumentId> = { docs: [], active: null };
private readonly pluginPanes = new Map<PluginTabId, Widget>();
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
plugins: PluginManager | null = null;
@@ -255,9 +277,9 @@ export class Editor implements PlayHost {
};
this.centre = new VStack();
this.tabs = new TabStrip<TabId>(
TAB_ORDER.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
(tab) => this.showTab(tab),
this.tabs = new TabStrip<StripTab>(
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
(tab) => this.pickTab(tab),
);
this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
@@ -287,8 +309,26 @@ export class Editor implements PlayHost {
list: this.listEditor,
plugins: this.pluginsPane,
};
this.assetEditors = {
graph: this.graphEditor,
statemachine: this.stateMachineEditor,
shader: this.shaderEditor,
particles: this.particlesEditor,
texture: this.textureEditor,
animation: this.animationEditor,
tile: this.tileEditor,
container: this.containerEditor,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
this.graphEditor.onRetarget = (from, to) => {
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
if (next === this.documents) return;
this.documents = next;
this.syncStrip();
this.showDocument(`asset:${to}`);
};
this.centre.add(this.tabs);
for (const tab of TAB_ORDER) {
for (const tab of PANE_ORDER) {
this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene';
}
@@ -332,45 +372,37 @@ export class Editor implements PlayHost {
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
});
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node, a frame and a template object exist only in their own tab, so selecting one
// switches to it. Selecting an asset never does: that is what opening it (double-click,
// Enter, creating it) is for – see the `open` event below.
// A node, a frame and a template object exist only in their own document, so selecting one
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
state.on('selection', () => {
const s = state.selection;
if (s?.kind === 'node') {
const tab = state.selectedStateMachineId
? 'statemachine'
: state.selectedShaderId
? 'shader'
: state.selectedParticlesId
? 'particles'
: 'graph';
if (this.tab !== tab) this.showTab(tab);
}
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
if (
s?.kind === 'object' &&
this.tab !== 'container' &&
findAsset(state.project, s.holderId)?.type === 'container'
)
this.showTab('container');
if (s?.kind === 'node' || s?.kind === 'frame') this.openDocument(`asset:${s.assetId}`);
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
this.openDocument(`asset:${s.holderId}`);
});
state.on('open', () => {
const s = state.selection;
const tab =
s?.kind === 'asset' ? TAB_OF_ASSET[findAsset(state.project, s.assetId)?.type ?? 'sprite'] : undefined;
if (tab) this.showTab(tab);
if (s?.kind !== 'asset') return;
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
else this.openDocument(`asset:${s.assetId}`);
});
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
state.on('list', () => {
if (state.listSlot) this.showTab('list');
else if (this.tab === 'list') this.showTab('scene');
if (state.listSlot) this.openDocument('list');
else this.closeDocument('list');
});
state.on('project', () => {
this.play.syncFromModel();
this.editorRun.invalidate();
this.syncDocuments();
});
state.on('load', () => {
this.editorRun.invalidate();
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.showTab('scene');
});
state.on('load', () => this.editorRun.invalidate());
state.on('mode', () => this.editorRun.invalidate());
}
@@ -379,14 +411,14 @@ export class Editor implements PlayHost {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
}
/** Appends a plugin's tab after the built-in ones; `hasTab` has said the id is free. */
/** Puts a plugin's tab after the fixed ones, ahead of the documents; `hasTab` has said the id is free. */
addPluginTab(id: string, label: string, content: Widget): void {
const tab: PluginTabId = `plugin:${id}`;
this.pluginPanes.set(tab, content);
content.visible = false;
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
content.flex = 1;
this.tabs.addTab(tab, label);
this.tabs.addTab(tab, label, FIXED_TABS.length + this.pluginPanes.size);
this.pluginPanes.set(tab, content);
this.centre.relayout();
}
@@ -410,20 +442,128 @@ export class Editor implements PlayHost {
return this.pluginPanes.get(`plugin:${id}`);
}
/** The documents open in the tab row, in order, and the one showing – for the check scripts. */
openDocuments(): { docs: DocumentId[]; active: DocumentId | null } {
return { docs: [...this.documents.docs], active: this.documents.active };
}
/** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */
openDocument(doc: DocumentId): void {
if (!this.kindOf(doc)) return;
if (this.documents.active === doc) {
// Still worth a look at the name: the List tab may have been pointed at another list.
this.syncStrip();
return;
}
this.documents = openDocument(this.documents, doc);
this.syncStrip();
this.showDocument(doc);
}
/** Closes a document's tab; nothing is lost, every edit is in the model already. */
closeDocument(doc: DocumentId): void {
if (!this.documents.docs.includes(doc)) return;
const wasShowing = this.documents.active === doc;
this.documents = closeDocument(this.documents, doc);
this.tabs.removeTab(doc);
const kind = this.kindOf(doc);
if (kind && kind !== 'list') this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
// The List tab edits the one slot there is; a closed tab lets go of it.
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
if (!wasShowing) return;
if (this.documents.active) this.showDocument(this.documents.active);
else this.showTab('scene');
}
/**
* Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the
* one the selection is about, else the one its editor last had – the way the check scripts reach
* a document without knowing its tab.
*/
showTab(tab: TabId): void {
if (tab === 'list') {
if (this.state.listSlot) this.openDocument('list');
return;
}
if (tab in this.assetEditors) {
const editor = this.assetEditors[tab as AssetKind];
const id =
editor.selectedId() ||
editor.currentAssetId ||
this.state.project.assets.find((a) => editor.isMine(a))?.id;
if (id && findAsset(this.state.project, id)) this.openDocument(`asset:${id}`);
else this.display(tab, null);
return;
}
this.display(tab, tab as FixedTab | PluginTabId);
}
private pickTab(tab: StripTab): void {
if (isDocument(tab)) this.openDocument(tab);
else this.showTab(tab);
}
private kindOf(doc: DocumentId): CentreTab | null {
if (doc === 'list') return this.state.listSlot ? 'list' : null;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
return kind && kind !== 'scene' ? kind : null;
}
private showDocument(doc: DocumentId): void {
const kind = this.kindOf(doc);
if (!kind) return;
if (kind !== 'list') this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
this.display(kind, doc);
}
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
private labelOf(doc: DocumentId): string {
if (doc === 'list') {
const slot = this.state.listSlot;
return (slot && resolveList(this.state, slot)?.title) ?? '';
}
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
}
/** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */
private syncStrip(): void {
for (const tab of [...this.tabs.tabs])
if (isDocument(tab) && !this.documents.docs.includes(tab)) this.tabs.removeTab(tab);
const base = FIXED_TABS.length + this.pluginPanes.size;
this.documents.docs.forEach((doc, index) => {
const kind = this.kindOf(doc);
if (!this.tabs.has(doc) && kind)
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
kind: TAB_LABELS[kind],
onClose: () => this.closeDocument(doc),
});
else this.tabs.setLabel(doc, this.labelOf(doc));
});
}
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
private syncDocuments(): void {
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
this.syncStrip();
}
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
private display(tab: TabId, strip: StripTab | null): void {
this.tab = tab;
this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null };
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
);
for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
for (const key of PANE_ORDER) this.panes[key].visible = key === tab;
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
// The view checks its own flag before it redraws, and the row around it is what switches now.
this.viewport.visible = tab === 'scene';
this.syncTilemapPanel();
this.tabs.setActive(tab);
this.tabs.setActive(strip);
this.centre.relayout();
ui().focus.validate();
}
@@ -441,8 +581,8 @@ export class Editor implements PlayHost {
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<TabId>[]; hidden: TabId[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
}
boot(): Promise<void> {