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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f546c0e08
commit
cc285af10a
33 files changed
+895
-225
No files matched your search
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1076 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1085 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -1,58 +1,57 @@
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { Button, Dropdown, Label, Menu, type MenuItem, Spacer, VStack, type Widget } from '../ui';
|
||||
import { Button, Label, Spacer, VStack, type Widget } from '../ui';
|
||||
import type { EditorState } from './state';
|
||||
import { TabHeader } from './TabHeader';
|
||||
|
||||
/**
|
||||
* The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much.
|
||||
* A canvas without `fit` belongs to a tab whose Fit frames something else (see `AssetTabEditor.fit`).
|
||||
* `saveView`/`restoreView` carry zoom and scroll across a switch to another document and back.
|
||||
*/
|
||||
export interface TabCanvas {
|
||||
readonly currentAssetId: string;
|
||||
show(assetId: string): void;
|
||||
fit?(): void;
|
||||
saveView?(): unknown;
|
||||
restoreView?(view: unknown): void;
|
||||
}
|
||||
|
||||
export interface AssetTabOptions {
|
||||
/** Shown left of the picker, and in the empty state as "No <noun>". */
|
||||
noun: string;
|
||||
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
|
||||
infoWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Behavior, State machine, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||||
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
|
||||
* The Behavior, State machine, Texture, Container … tabs. One editor per kind serves every open
|
||||
* document of that kind (#171): the tab row tells it which one to show, and it keeps each one's
|
||||
* view for when it comes back. A subclass must call `mount()`: header and canvas depend on each
|
||||
* other, so the base cannot build them.
|
||||
*/
|
||||
export abstract class AssetTabEditor<
|
||||
A extends Asset,
|
||||
C extends TabCanvas & Widget = TabCanvas & Widget,
|
||||
> extends VStack {
|
||||
/** Public for the check scripts. */
|
||||
readonly picker: Dropdown;
|
||||
readonly fitButton: Button;
|
||||
protected readonly info: Label;
|
||||
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
|
||||
canvas!: C;
|
||||
private readonly views = new Map<string, unknown>();
|
||||
|
||||
constructor(
|
||||
protected readonly state: EditorState,
|
||||
private readonly options: AssetTabOptions,
|
||||
options: AssetTabOptions,
|
||||
) {
|
||||
super();
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
this.fitButton = new Button('Fit', { onClick: () => this.fit() });
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
this.info.minWidth = options.infoWidth;
|
||||
}
|
||||
|
||||
/** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */
|
||||
/** Builds the header (with `strip` below it) around `canvas` and wires the state events. */
|
||||
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
|
||||
this.canvas = canvas;
|
||||
const header = new TabHeader();
|
||||
header.add(new Label(this.options.noun, { style: 'muted' }));
|
||||
header.add(this.picker);
|
||||
for (const widget of extras) header.add(widget);
|
||||
header.add(this.fitButton);
|
||||
header.add(new Spacer(), 1);
|
||||
@@ -62,13 +61,13 @@ export abstract class AssetTabEditor<
|
||||
if (strip) this.add(strip);
|
||||
this.add(this.body(canvas), 1);
|
||||
|
||||
this.state.on('selection', () => this.followSelection());
|
||||
this.state.on('project', () => this.refresh());
|
||||
this.state.on('load', () => {
|
||||
this.views.clear();
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
this.refresh();
|
||||
});
|
||||
this.followSelection();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The asset currently shown, `''` for none – the check scripts read this. */
|
||||
@@ -81,10 +80,27 @@ export abstract class AssetTabEditor<
|
||||
return this.info.text;
|
||||
}
|
||||
|
||||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||||
protected abstract selectedId(): string;
|
||||
/** Shows a document, with the view it had when it was last on screen. */
|
||||
showAsset(assetId: string): void {
|
||||
const from = this.canvas.currentAssetId;
|
||||
if (from !== assetId) {
|
||||
if (from) this.views.set(from, this.saveView());
|
||||
this.canvas.show(assetId);
|
||||
const view = this.views.get(assetId);
|
||||
if (view !== undefined) this.restoreView(view);
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected abstract isMine(asset: Asset): asset is A;
|
||||
/** A closed tab: opened again, the document starts from a fitted view. */
|
||||
forgetView(assetId: string): void {
|
||||
this.views.delete(assetId);
|
||||
}
|
||||
|
||||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||||
abstract selectedId(): string;
|
||||
|
||||
abstract isMine(asset: Asset): asset is A;
|
||||
|
||||
/** The right-hand readout; `null` is the empty state. */
|
||||
protected abstract infoText(asset: A | null): string;
|
||||
@@ -99,6 +115,15 @@ export abstract class AssetTabEditor<
|
||||
this.canvas.fit?.();
|
||||
}
|
||||
|
||||
/** The view to come back to; override when the tab has more than one. */
|
||||
protected saveView(): unknown {
|
||||
return this.canvas.saveView?.();
|
||||
}
|
||||
|
||||
protected restoreView(view: unknown): void {
|
||||
this.canvas.restoreView?.(view);
|
||||
}
|
||||
|
||||
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||||
protected syncExtras(_asset: A | null): void {}
|
||||
|
||||
@@ -107,36 +132,9 @@ export abstract class AssetTabEditor<
|
||||
return asset && this.isMine(asset) ? asset : null;
|
||||
}
|
||||
|
||||
protected candidates(): A[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is A => this.isMine(a))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
protected followSelection(): void {
|
||||
const id = this.selectedId();
|
||||
// An empty id merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected refresh(): void {
|
||||
// A deleted asset leaves the canvas empty; fall back to whatever is left of this kind.
|
||||
if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`);
|
||||
this.info.setText(asset ? this.infoText(asset) : '');
|
||||
this.syncExtras(asset);
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.candidates().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0)
|
||||
items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js';
|
||||
import { Button, centreY, Theme, textStyle } from '../ui';
|
||||
|
||||
const GAP = 4;
|
||||
const CLOSE = '×';
|
||||
/** The × is small; the hit around it is not, or closing a tab would take aim. */
|
||||
const CLOSE_SLOP = 4;
|
||||
/** A long name is cut here rather than pushing every other tab behind the `»` button. */
|
||||
const MAX_NAME_WIDTH = 160;
|
||||
|
||||
/**
|
||||
* A tab that stands for an open document (#171): its kind in muted type before the name, so three
|
||||
* assets called "Player" can be told apart, and an × that closes it – as does a middle click.
|
||||
*/
|
||||
export class DocumentTab extends Button {
|
||||
private readonly kindText: Text;
|
||||
private readonly closeText: Text;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private readonly kind: string,
|
||||
onPick: () => void,
|
||||
private readonly onClose: () => void,
|
||||
) {
|
||||
super(label, { variant: 'ghost', padding: 4, align: 'left', onClick: onPick });
|
||||
this.kindText = new Text({ text: kind, style: textStyle('muted') });
|
||||
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
||||
this.closeText.eventMode = 'static';
|
||||
this.closeText.cursor = Theme.hoverCursor;
|
||||
// Stopped on the way down as well, or the tab under it would count the press as its own click.
|
||||
this.closeText.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||||
this.closeText.on('pointerup', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
this.onClose();
|
||||
});
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 1) this.onClose();
|
||||
});
|
||||
this.addChild(this.kindText, this.closeText);
|
||||
}
|
||||
|
||||
/** The × in global coordinates, for the check scripts. */
|
||||
closePoint(): { x: number; y: number } {
|
||||
const at = this.closeText.getGlobalPosition();
|
||||
return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 };
|
||||
}
|
||||
|
||||
get kindLabel(): string {
|
||||
return this.kind;
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
const own = super.measure();
|
||||
return { w: Math.min(own.w, MAX_NAME_WIDTH + this.padding * 2) + this.extraWidth(), h: own.h };
|
||||
}
|
||||
|
||||
protected override labelSlot(): number {
|
||||
return super.labelSlot() - this.extraWidth();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const style = textStyle(this.toggled ? 'accent' : 'muted');
|
||||
this.kindText.style = style;
|
||||
this.closeText.style = style;
|
||||
// On the accent fill the muted grey would vanish; the accent text, dimmed, stays the quieter one.
|
||||
this.kindText.alpha = this.toggled ? 0.7 : 1;
|
||||
this.kindText.position.set(this.padding, centreY(this.h, this.kindText.height));
|
||||
this.labelText.x = this.padding + this.kindText.width + GAP;
|
||||
const closeX = this.w - this.padding - this.closeText.width;
|
||||
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
||||
this.closeText.hitArea = new Rectangle(
|
||||
-CLOSE_SLOP,
|
||||
-CLOSE_SLOP,
|
||||
this.closeText.width + CLOSE_SLOP * 2,
|
||||
this.closeText.height + CLOSE_SLOP * 2,
|
||||
);
|
||||
}
|
||||
|
||||
private extraWidth(): number {
|
||||
const style = textStyle('muted');
|
||||
const kind = CanvasTextMetrics.measureText(this.kind, style).width;
|
||||
const close = CanvasTextMetrics.measureText(CLOSE, style).width;
|
||||
return Math.ceil(kind + close) + GAP * 2;
|
||||
}
|
||||
}
|
||||
+179
-39
@@ -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> {
|
||||
|
||||
+57
-27
@@ -1,4 +1,5 @@
|
||||
import { Button, HStack, Menu } from '../ui';
|
||||
import { DocumentTab } from './DocumentTab';
|
||||
import { tabRowLayout } from './tabRow';
|
||||
|
||||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||||
@@ -11,6 +12,15 @@ export interface TabPlace<T extends string> {
|
||||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||||
y: number;
|
||||
h: number;
|
||||
/** A document's kind as shown before its name, and where its × is. */
|
||||
kind?: string;
|
||||
close?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** A document's tab: its kind before the name, and an × that calls `onClose`. */
|
||||
export interface DocumentSpec {
|
||||
kind: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +29,10 @@ export interface TabPlace<T extends string> {
|
||||
*/
|
||||
export class TabStrip<T extends string> extends HStack {
|
||||
private readonly order: T[] = [];
|
||||
private readonly labels = new Map<T, string>();
|
||||
private readonly buttons = new Map<T, Button>();
|
||||
private readonly overflow: Button;
|
||||
private hiddenTabs: T[] = [];
|
||||
private active: T;
|
||||
private active: T | null;
|
||||
|
||||
constructor(
|
||||
tabs: readonly { tab: T; label: string }[],
|
||||
@@ -37,44 +46,59 @@ export class TabStrip<T extends string> extends HStack {
|
||||
onClick: () => this.openMenu(),
|
||||
});
|
||||
this.add(this.overflow);
|
||||
for (const { tab, label } of tabs) this.place(tab, label);
|
||||
for (const { tab, label } of tabs) this.place(tab, label, this.order.length);
|
||||
this.buttons.get(this.active)!.setToggled(true);
|
||||
}
|
||||
|
||||
/** Appends a tab at the end of the row, in front of the `»` button. */
|
||||
addTab(tab: T, label: string): void {
|
||||
this.place(tab, label);
|
||||
/** Puts a tab at `at` in the row (default: last, in front of the `»` button). */
|
||||
addTab(tab: T, label: string, at = this.order.length, document?: DocumentSpec): void {
|
||||
this.place(tab, label, at, document);
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
/** Takes a tab out of the row again; a plugin switched off (#114). Unknown tabs are ignored. */
|
||||
/** Takes a tab out of the row again. Unknown tabs are ignored. */
|
||||
removeTab(tab: T): void {
|
||||
const button = this.buttons.get(tab);
|
||||
if (!button) return;
|
||||
this.order.splice(this.order.indexOf(tab), 1);
|
||||
this.labels.delete(tab);
|
||||
this.buttons.delete(tab);
|
||||
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
||||
button.destroy({ children: true });
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
private place(tab: T, label: string): void {
|
||||
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
||||
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
||||
const button = new Button(label, {
|
||||
variant: 'ghost',
|
||||
padding: 4,
|
||||
onClick: () => this.onPick(tab),
|
||||
});
|
||||
this.order.push(tab);
|
||||
this.labels.set(tab, label);
|
||||
this.buttons.set(tab, button);
|
||||
this.add(button);
|
||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||
/** A renamed document; the row measures again, since the name decides the width. */
|
||||
setLabel(tab: T, label: string): void {
|
||||
const button = this.buttons.get(tab);
|
||||
if (!button || button.text === label) return;
|
||||
button.setText(label);
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
setActive(tab: T): void {
|
||||
has(tab: T): boolean {
|
||||
return this.buttons.has(tab);
|
||||
}
|
||||
|
||||
/** Every tab in row order, hidden ones included. */
|
||||
get tabs(): readonly T[] {
|
||||
return this.order;
|
||||
}
|
||||
|
||||
private place(tab: T, label: string, at: number, document?: DocumentSpec): void {
|
||||
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
||||
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
||||
const button = document
|
||||
? new DocumentTab(label, document.kind, () => this.onPick(tab), document.onClose)
|
||||
: new Button(label, { variant: 'ghost', padding: 4, onClick: () => this.onPick(tab) });
|
||||
this.order.splice(at, 0, tab);
|
||||
this.buttons.set(tab, button);
|
||||
// The `»` button is the first child, so a tab's index among the children is one past its place.
|
||||
this.addChildAt(button, at + 1);
|
||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||
button.setToggled(tab === this.active);
|
||||
}
|
||||
|
||||
setActive(tab: T | null): void {
|
||||
this.active = tab;
|
||||
for (const [key, button] of this.buttons) button.setToggled(key === tab);
|
||||
// Not `resize`: the strip keeps its size while a different tab has to come into view.
|
||||
@@ -88,7 +112,12 @@ export class TabStrip<T extends string> extends HStack {
|
||||
const button = this.buttons.get(tab)!;
|
||||
if (!button.visible) continue;
|
||||
const at = button.toGlobal({ x: 0, y: 0 });
|
||||
out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h });
|
||||
const place: TabPlace<T> = { tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h };
|
||||
if (button instanceof DocumentTab) {
|
||||
place.kind = button.kindLabel;
|
||||
place.close = button.closePoint();
|
||||
}
|
||||
out.push(place);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -118,10 +147,11 @@ export class TabStrip<T extends string> extends HStack {
|
||||
}
|
||||
|
||||
private openMenu(): void {
|
||||
const items = this.hiddenTabs.map((tab) => ({
|
||||
label: this.labels.get(tab)!,
|
||||
onSelect: () => this.onPick(tab),
|
||||
}));
|
||||
const items = this.hiddenTabs.map((tab) => {
|
||||
const button = this.buttons.get(tab)!;
|
||||
const kind = button instanceof DocumentTab ? `${button.kindLabel} · ` : '';
|
||||
return { label: `${kind}${button.text}`, onSelect: () => this.onPick(tab) };
|
||||
});
|
||||
new Menu(items).openBelow(this.overflow);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
readonly preview: PreviewCanvas;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Animation', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() });
|
||||
this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() });
|
||||
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
|
||||
@@ -44,6 +44,18 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
this.preview.fit();
|
||||
}
|
||||
|
||||
protected override saveView(): unknown {
|
||||
return { timeline: this.canvas.saveView(), preview: this.preview.saveView() };
|
||||
}
|
||||
|
||||
protected override restoreView(view: unknown): void {
|
||||
const saved = view as { timeline: unknown; preview: unknown };
|
||||
this.canvas.restoreView(saved.timeline);
|
||||
// Pointed at the document first: `syncExtras` would do it later and fit it over the restored view.
|
||||
this.preview.show(this.currentAssetId);
|
||||
this.preview.restoreView(saved.preview);
|
||||
}
|
||||
|
||||
/** The preview above, the timeline below, each taking half. */
|
||||
protected override body(canvas: TimelineCanvas): Widget {
|
||||
const stack = new VStack();
|
||||
@@ -65,11 +77,11 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
return this.canvas.isPlaying;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedAnimationId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is AnimationAsset {
|
||||
override isMine(asset: Asset): asset is AnimationAsset {
|
||||
return asset.type === 'animation';
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ export class PreviewCanvas extends FreeViewport {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
override saveView(): unknown {
|
||||
return { view: super.saveView(), fitPending: this.fitPending };
|
||||
}
|
||||
|
||||
override restoreView(saved: unknown): void {
|
||||
const { view, fitPending } = saved as { view: unknown; fitPending: boolean };
|
||||
if (fitPending) return;
|
||||
this.fitPending = false;
|
||||
super.restoreView(view);
|
||||
}
|
||||
|
||||
override fit(): void {
|
||||
this.fitPending = false;
|
||||
super.fit();
|
||||
|
||||
@@ -143,6 +143,15 @@ export class TimelineCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
saveView(): TimelineView {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as TimelineView) };
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingNow;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Asset, ContainerAsset } from '../../core/model/Asset';
|
||||
import { activeDrag } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { ContainerCanvas } from './ContainerCanvas';
|
||||
@@ -9,7 +8,7 @@ const INFO_WIDTH = 110;
|
||||
|
||||
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Container', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new ContainerCanvas(state));
|
||||
}
|
||||
|
||||
@@ -29,14 +28,11 @@ export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCan
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
// A dragged row selects on release, and whether that lands before or after the drop is up
|
||||
// to Pixi's event order. Following it in between would swap the tab to the very container
|
||||
// being dragged, which would then nest in itself, so the target stays put while a drag runs.
|
||||
return activeDrag() ? '' : (this.state.selectedContainerId ?? '');
|
||||
override selectedId(): string {
|
||||
return this.state.selectedContainerId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ContainerAsset {
|
||||
override isMine(asset: Asset): asset is ContainerAsset {
|
||||
return asset.type === 'container';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { currentFramesDialog } from './animation/FramesDialog';
|
||||
import { currentRowDialog } from './animation/RowDialog';
|
||||
import type { Editor, TabId } from './Editor';
|
||||
import type { DocumentId, Editor, TabId } from './Editor';
|
||||
import { NodeBoard } from './plugins/NodeBoard';
|
||||
import { currentFontPicker } from './font/FontPicker';
|
||||
import { currentNodePalette } from './graph/NodePalette';
|
||||
@@ -365,15 +365,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
pickerWidth: () => graphEditor.picker.w,
|
||||
pickerAt: () => centreOf(graphEditor.picker),
|
||||
snapping: () => graphEditor.canvas.snapEnabled,
|
||||
zoom: () => graphEditor.canvas.zoomLevel,
|
||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||
},
|
||||
stateMachine: {
|
||||
current: () => editor.stateMachineEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.stateMachineEditor.picker),
|
||||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||||
@@ -401,7 +398,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.particlesEditor.picker),
|
||||
info: () => editor.particlesEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||||
@@ -426,7 +422,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
shader: {
|
||||
current: () => editor.shaderEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.shaderEditor.picker),
|
||||
info: () => editor.shaderEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId),
|
||||
@@ -474,7 +469,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
pickerWidth: () => textureEditor.picker.w,
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
rect: () => textureEditor.canvasRect(),
|
||||
@@ -630,7 +624,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
pickerWidth: () => containerEditor.picker.w,
|
||||
center: () => centreOf(containerEditor),
|
||||
rect: () => containerEditor.canvasRect(),
|
||||
/** Where a template object is drawn, so a check can click or sample it. */
|
||||
@@ -782,6 +775,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
?.desynchronized === true,
|
||||
tab: () => editor.tab,
|
||||
showTab: (tab: TabId) => editor.showTab(tab),
|
||||
/** The open documents in tab order and the one showing (#171); a document is `asset:<id>` or `list`. */
|
||||
documents: () => editor.openDocuments(),
|
||||
openDocument: (doc: DocumentId) => editor.openDocument(doc),
|
||||
closeDocument: (doc: DocumentId) => editor.closeDocument(doc),
|
||||
plugins: {
|
||||
/** Every file in `data/plugins/` and what became of it. */
|
||||
records: () => [...(editor.plugins?.records ?? [])],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The open documents of the tab row (#171): which tabs there are, in which order, and which one is
|
||||
* showing. `null` as the active one means a fixed tab (Scene, Game, Plugins, a plugin's) is showing.
|
||||
*/
|
||||
export interface DocumentRow<T> {
|
||||
docs: readonly T[];
|
||||
active: T | null;
|
||||
}
|
||||
|
||||
/** Opens `doc`: an open one merely becomes active, a new one goes right of the active document, or last. */
|
||||
export function openDocument<T>(row: DocumentRow<T>, doc: T): DocumentRow<T> {
|
||||
if (row.docs.includes(doc)) return { docs: row.docs, active: doc };
|
||||
const at = row.active === null ? -1 : row.docs.indexOf(row.active);
|
||||
const docs = [...row.docs];
|
||||
docs.splice(at < 0 ? docs.length : at + 1, 0, doc);
|
||||
return { docs, active: doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes `doc`. Closing the one on screen shows its left neighbour, or the right one when it was
|
||||
* first; with none left, `active` is null and the caller goes back to the scene.
|
||||
*/
|
||||
export function closeDocument<T>(row: DocumentRow<T>, doc: T): DocumentRow<T> {
|
||||
const at = row.docs.indexOf(doc);
|
||||
if (at < 0) return row;
|
||||
const docs = row.docs.filter((d) => d !== doc);
|
||||
if (row.active !== doc) return { docs, active: row.active };
|
||||
return { docs, active: docs[Math.max(0, at - 1)] ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts `to` where `from` is – a tab that goes on to another document, like a behavior followed
|
||||
* into a call. Where `to` was open already, that tab goes: a document has one tab.
|
||||
*/
|
||||
export function replaceDocument<T>(row: DocumentRow<T>, from: T, to: T): DocumentRow<T> {
|
||||
if (from === to || !row.docs.includes(from)) return row;
|
||||
const docs = row.docs.filter((d) => d !== to).map((d) => (d === from ? to : d));
|
||||
return { docs, active: row.active === from || row.active === to ? to : row.active };
|
||||
}
|
||||
@@ -240,6 +240,19 @@ export class GraphCanvas extends Widget {
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
saveView(): GraphViewState {
|
||||
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
const saved = view as GraphViewState;
|
||||
if (saved.needsFit) return;
|
||||
this.pan = { ...saved.pan };
|
||||
this.zoom = saved.zoom;
|
||||
this.needsFit = false;
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
/** Takes effect on the next drag only – turning it on must not shuffle the graph. */
|
||||
setSnapping(on: boolean): void {
|
||||
this.snapping = on;
|
||||
@@ -1322,6 +1335,13 @@ export class GraphCanvas extends Widget {
|
||||
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/** Zoom and scroll of one graph, kept while another document is on screen (#171). */
|
||||
interface GraphViewState {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
needsFit: boolean;
|
||||
}
|
||||
|
||||
function globalPoint(target: Container, local: { x: number; y: number }): { x: number; y: number } {
|
||||
const g = target.toGlobal({ x: local.x, y: local.y });
|
||||
return { x: g.x, y: g.y };
|
||||
|
||||
@@ -14,9 +14,11 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly breadcrumb = new Breadcrumb();
|
||||
private trail: Trail = [];
|
||||
/** A call followed into another behavior: the tab goes on to that document (#171). */
|
||||
onRetarget: (from: string, to: string) => void = () => undefined;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.breadcrumb.onPick = (index) => this.open(this.trail[index]!, backTo(this.trail, index));
|
||||
this.mount(new GraphCanvas(state), this.snap.widgets, this.breadcrumb);
|
||||
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
|
||||
@@ -29,10 +31,26 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
* appearing afterwards would cut the bottom of the fitted graph off.
|
||||
*/
|
||||
private open(assetId: string, trail: Trail): void {
|
||||
const from = this.currentAssetId;
|
||||
this.trail = trail;
|
||||
this.showTrail();
|
||||
// A fresh look: fitted below this trail, not the view (and trail) kept from an earlier visit.
|
||||
this.forgetView(assetId);
|
||||
this.showAsset(assetId);
|
||||
this.onRetarget(from, assetId);
|
||||
this.state.select({ kind: 'asset', assetId });
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The trail belongs to the tab's document, so it comes back with the view. */
|
||||
protected override saveView(): unknown {
|
||||
return { canvas: super.saveView(), trail: this.trail };
|
||||
}
|
||||
|
||||
protected override restoreView(view: unknown): void {
|
||||
const saved = view as { canvas: unknown; trail: Trail };
|
||||
this.trail = saved.trail;
|
||||
this.showTrail();
|
||||
super.restoreView(saved.canvas);
|
||||
}
|
||||
|
||||
protected override refresh(): void {
|
||||
@@ -49,11 +67,11 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
if (this.breadcrumb.show(names)) this.relayout();
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedBehaviorId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
return asset.type === 'behavior';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
private previewWidth = 280;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Particles', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.preview = new ParticlePreview(state);
|
||||
this.slider.onChange = (value) => {
|
||||
this.preview.hold(value);
|
||||
@@ -130,11 +130,11 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
this.slider.setValue(holding ? time : time % this.span);
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedParticlesId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ParticlesAsset {
|
||||
override isMine(asset: Asset): asset is ParticlesAsset {
|
||||
return asset.type === 'particles';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Shader', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedShaderId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ShaderAsset {
|
||||
override isMine(asset: Asset): asset is ShaderAsset {
|
||||
return asset.type === 'shader';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphC
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'State machine', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new GraphCanvas(state, 'state machine'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedStateMachineId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is StateMachineAsset {
|
||||
override isMine(asset: Asset): asset is StateMachineAsset {
|
||||
return asset.type === 'statemachine';
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface TabRowInput<T extends string> {
|
||||
gap: number;
|
||||
/** Width of the button that opens the hidden ones; only claimed when something is hidden. */
|
||||
overflowWidth: number;
|
||||
active: T;
|
||||
active: T | null;
|
||||
}
|
||||
|
||||
export interface TabRowLayout<T extends string> {
|
||||
|
||||
@@ -178,6 +178,18 @@ export class TextureCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
saveView(): { view: SheetView; needsFit: boolean } {
|
||||
return { view: { ...this.view }, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { ...view };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
if (this.collision === on) return;
|
||||
this.collision = on;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
private readonly collisionButton: Button;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
|
||||
this.collisionButton = new Button('Collision', {
|
||||
toggle: true,
|
||||
@@ -40,11 +40,11 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedTextureId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is TextureAsset {
|
||||
override isMine(asset: Asset): asset is TextureAsset {
|
||||
return asset.type === 'texture';
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,18 @@ export class TileCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
saveView(): { view: GraphView; needsFit: boolean } {
|
||||
return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: GraphView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { pan: { ...view.pan }, zoom: view.zoom };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Advances the animation; only called while the tab is visible (see `Editor.tick`). */
|
||||
tick(dt: number): void {
|
||||
if (!this.animates) return;
|
||||
|
||||
@@ -25,7 +25,7 @@ export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
|
||||
readonly strip: PictureStrip;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Tile', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.kindPicker = new Dropdown('—', { width: KIND_WIDTH, onClick: () => this.openKindPicker() });
|
||||
this.strip = new PictureStrip(state);
|
||||
this.mount(new TileCanvas(state), [this.kindPicker]);
|
||||
@@ -49,11 +49,11 @@ export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
|
||||
return this.canvas.animates;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedTileId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is TileAsset {
|
||||
override isMine(asset: Asset): asset is TileAsset {
|
||||
return asset.type === 'tile';
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,16 @@ export abstract class FreeViewport extends WorldViewport {
|
||||
this.screenOrigin = this.getGlobalPosition();
|
||||
}
|
||||
|
||||
/** Where the camera stands, to come back to (#171). */
|
||||
saveView(): unknown {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as FreeViewport['view']) };
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected resetView(): void {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface ButtonOptions {
|
||||
|
||||
export class Button extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText: Text;
|
||||
protected readonly labelText: Text;
|
||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||
private fullText: string;
|
||||
private pressed = false;
|
||||
@@ -32,7 +32,7 @@ export class Button extends Widget {
|
||||
variant: NonNullable<ButtonOptions['variant']>;
|
||||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||||
protected readonly fixedWidth: number;
|
||||
private readonly padding: number;
|
||||
protected readonly padding: number;
|
||||
private readonly style: TextStyleToken;
|
||||
private readonly toggle: boolean;
|
||||
onClick?: () => void;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { closeDocument, openDocument, replaceDocument } from '../../src/editor/documents';
|
||||
import { tabRowLayout } from '../../src/editor/tabRow';
|
||||
|
||||
const row = (docs: string[], active: string | null) => ({ docs, active });
|
||||
|
||||
describe('open documents', () => {
|
||||
it('puts a new document right of the one showing', () => {
|
||||
expect(openDocument(row(['a', 'b', 'c'], 'a'), 'x')).toEqual(row(['a', 'x', 'b', 'c'], 'x'));
|
||||
});
|
||||
|
||||
it('puts it last while a fixed tab is showing', () => {
|
||||
expect(openDocument(row(['a', 'b'], null), 'x')).toEqual(row(['a', 'b', 'x'], 'x'));
|
||||
expect(openDocument(row([], null), 'x')).toEqual(row(['x'], 'x'));
|
||||
});
|
||||
|
||||
it('only brings an open document up – one tab per document', () => {
|
||||
expect(openDocument(row(['a', 'b', 'c'], 'c'), 'a')).toEqual(row(['a', 'b', 'c'], 'a'));
|
||||
});
|
||||
|
||||
it('shows the left neighbour after the one showing closes', () => {
|
||||
expect(closeDocument(row(['a', 'b', 'c'], 'b'), 'b')).toEqual(row(['a', 'c'], 'a'));
|
||||
});
|
||||
|
||||
it('shows the right one when the first closes, and nothing when the last goes', () => {
|
||||
expect(closeDocument(row(['a', 'b'], 'a'), 'a')).toEqual(row(['b'], 'b'));
|
||||
expect(closeDocument(row(['a'], 'a'), 'a')).toEqual(row([], null));
|
||||
});
|
||||
|
||||
it('keeps what is showing when another tab closes', () => {
|
||||
expect(closeDocument(row(['a', 'b', 'c'], 'c'), 'a')).toEqual(row(['b', 'c'], 'c'));
|
||||
expect(closeDocument(row(['a', 'b'], null), 'b')).toEqual(row(['a'], null));
|
||||
});
|
||||
|
||||
it('ignores a document that is not open', () => {
|
||||
const before = row(['a'], 'a');
|
||||
expect(closeDocument(before, 'x')).toBe(before);
|
||||
});
|
||||
|
||||
it('lets a tab go on to another document in its place', () => {
|
||||
expect(replaceDocument(row(['a', 'b', 'c'], 'b'), 'b', 'x')).toEqual(row(['a', 'x', 'c'], 'x'));
|
||||
});
|
||||
|
||||
it('drops the other tab of a document it goes on to', () => {
|
||||
expect(replaceDocument(row(['a', 'b', 'c'], 'a'), 'a', 'c')).toEqual(row(['c', 'b'], 'c'));
|
||||
});
|
||||
|
||||
it('leaves the row alone for a tab that is not open', () => {
|
||||
const before = row(['a'], 'a');
|
||||
expect(replaceDocument(before, 'x', 'y')).toBe(before);
|
||||
expect(replaceDocument(before, 'a', 'a')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab row without an active tab', () => {
|
||||
it('fills the row in order when a kind with no document is showing', () => {
|
||||
const tabs = [
|
||||
{ key: 'a', width: 50 },
|
||||
{ key: 'b', width: 50 },
|
||||
{ key: 'c', width: 50 },
|
||||
];
|
||||
const layout = tabRowLayout({ tabs, available: 132, gap: 2, overflowWidth: 28, active: null });
|
||||
expect(layout).toEqual({ shown: ['a', 'b'], hidden: ['c'] });
|
||||
});
|
||||
});
|
||||
+17
-11
@@ -351,21 +351,27 @@ export async function graph(t) {
|
||||
'graph editor keeps its behavior for a non-behavior asset',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
// The picker is the *last* "Mover" on screen: the assets panel shows one too.
|
||||
const pickerAt = await locate('Mover', true);
|
||||
await click(pickerAt.x, pickerAt.y);
|
||||
const spawnerRow = await locate('Spawner', true);
|
||||
await click(spawnerRow.x, spawnerRow.y);
|
||||
await select({ kind: 'asset', assetId: 'spawner' });
|
||||
check(
|
||||
'the header picker switches the graph to another behavior',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'spawner',
|
||||
'selecting another behavior leaves the tab on its own (#171)',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
await evaluate(`__gm.state.openAsset('spawner')`);
|
||||
const twoOpen = await json('__gm.documents()');
|
||||
check(
|
||||
'opening it brings it up in a tab of its own',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'spawner' &&
|
||||
twoOpen.active === 'asset:spawner' &&
|
||||
twoOpen.docs.includes('asset:mover'),
|
||||
JSON.stringify(twoOpen),
|
||||
);
|
||||
await evaluate(`__gm.closeDocument('asset:spawner')`);
|
||||
check(
|
||||
'closing it shows the behavior next to it again',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover' && (await json('__gm.tab()')) === 'graph',
|
||||
String(await evaluate(`__gm.graph.current()`)),
|
||||
);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
check(
|
||||
'graph editor rebinds when a behavior is selected again',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
|
||||
// 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
|
||||
// under it included – and the left button no longer pans at all.
|
||||
|
||||
+30
-34
@@ -1,43 +1,35 @@
|
||||
import { TAB_LABELS } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Labels must be shortened to fit their control, not written past it. Runs last because it renames
|
||||
* assets to absurd lengths to provoke the case.
|
||||
*/
|
||||
|
||||
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
|
||||
/** A name far wider than a tab may grow, so a tab that does not cut it must spill. */
|
||||
const LONG_NAME = 'Player movement with jumping and coyote time';
|
||||
|
||||
export async function overflow(t) {
|
||||
const { check, sleep, json, evaluate, shot, mutate, drag, clickText, showTab } = t;
|
||||
|
||||
const pickerWidths = async () => {
|
||||
const out = {};
|
||||
for (const tab of ['graph', 'texture', 'container']) {
|
||||
await showTab(tab);
|
||||
out[tab] = await json(`__gm.${tab}.pickerWidth()`);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const before = await pickerWidths();
|
||||
|
||||
await mutate(`
|
||||
for (const asset of __gm.state.project.assets) {
|
||||
if (asset.type === 'behavior' || asset.type === 'texture' || asset.type === 'container')
|
||||
asset.name = ${JSON.stringify(LONG_NAME)};
|
||||
}
|
||||
__gm.state.select(null)`);
|
||||
await sleep(600);
|
||||
|
||||
// 14a. The pickers keep the width they were given instead of growing with the name they show.
|
||||
const after = await pickerWidths();
|
||||
// 14a. A document's tab shows its name, but a long one is cut instead of taking the row (#171).
|
||||
// The three do not fit side by side at this width, so each is measured while it shows.
|
||||
const widths = [];
|
||||
for (const tab of ['graph', 'texture', 'container']) {
|
||||
check(
|
||||
`the ${tab} picker keeps its width when the name gets long`,
|
||||
before[tab] === 200 && after[tab] === 200,
|
||||
`before ${before[tab]}, after ${after[tab]}`,
|
||||
);
|
||||
await showTab(tab);
|
||||
const { active } = await json('__gm.documents()');
|
||||
const shown = (await json('__gm.tabRow()')).shown.find((b) => b.tab === active);
|
||||
widths.push(shown ? Math.round(shown.right - shown.x) : null);
|
||||
}
|
||||
await sleep(600);
|
||||
check(
|
||||
'three documents with long names each keep a tab of bounded width',
|
||||
(await json('__gm.documents()')).docs.length === 3 && widths.every((w) => w !== null && w < 300),
|
||||
JSON.stringify(widths),
|
||||
);
|
||||
|
||||
// 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and
|
||||
// a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide).
|
||||
@@ -53,8 +45,7 @@ export async function overflow(t) {
|
||||
spillOf(wide, await columnRight()).length === 0,
|
||||
JSON.stringify({ spilled: spillOf(wide, await columnRight()), hidden: wide.hidden }),
|
||||
);
|
||||
// The tenth tab (Particles, #73) is the case #84 was built for: 631 px of nine tabs already left
|
||||
// 35 px in a 1280 px window, so at ten the row has to put something away even undisturbed.
|
||||
// Three open documents on top of the fixed tabs no longer fit a 1280 px window.
|
||||
check(
|
||||
'at the test window’s width the row already needs its overflow, and still shows what is edited',
|
||||
wide.hidden.length > 0 && wide.shown.some((b) => b.tab === 'scene'),
|
||||
@@ -84,20 +75,23 @@ export async function overflow(t) {
|
||||
);
|
||||
await shot('35-tab-overflow');
|
||||
// The overflow button names them, and picking one brings it into the row.
|
||||
const wanted = narrow.hidden[narrow.hidden.length - 1];
|
||||
await clickText('»');
|
||||
const offered = await json('__gm.overlayTexts()');
|
||||
const offered = (await json('__gm.menuRows()')).filter(Boolean);
|
||||
check(
|
||||
'the overflow button offers every tab that had to go',
|
||||
narrow.hidden.every((tab) => offered.includes(TAB_LABELS[tab])),
|
||||
offered.length === narrow.hidden.length &&
|
||||
offered.filter((label) => label.includes(' · ')).length ===
|
||||
narrow.hidden.filter((tab) => tab.startsWith('asset:')).length,
|
||||
JSON.stringify({ offered, hidden: narrow.hidden }),
|
||||
);
|
||||
await clickText(TAB_LABELS[wanted]);
|
||||
const wanted = narrow.hidden[narrow.hidden.length - 1];
|
||||
const item = await t.locate(offered[offered.length - 1], true);
|
||||
await t.click(item.x, item.y);
|
||||
const picked = await json('__gm.tabRow()');
|
||||
check(
|
||||
`picking ${TAB_LABELS[wanted]} from the overflow menu opens it and puts it in the row`,
|
||||
(await json('__gm.tab()')) === wanted && picked.shown.some((b) => b.tab === wanted),
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), shown: picked.shown.map((b) => b.tab) }),
|
||||
'picking a document from the overflow menu opens it and puts it in the row',
|
||||
(await json('__gm.documents()')).active === wanted && picked.shown.some((b) => b.tab === wanted),
|
||||
JSON.stringify({ wanted, docs: await json('__gm.documents()'), shown: picked.shown.map((b) => b.tab) }),
|
||||
);
|
||||
await widenPanels(-250);
|
||||
await showTab('scene');
|
||||
@@ -136,7 +130,7 @@ export async function overflow(t) {
|
||||
);
|
||||
|
||||
// 14c. Every tab, because each one owns a different set of controls.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list', 'particles']) {
|
||||
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'particles']) {
|
||||
await showTab(tab);
|
||||
const spills = await json('__gm.textOverflows()');
|
||||
check(
|
||||
@@ -150,7 +144,7 @@ export async function overflow(t) {
|
||||
await showTab('graph');
|
||||
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
|
||||
check(
|
||||
'a value too wide for its dropdown is cut with an ellipsis',
|
||||
'a name too wide for its tab is cut with an ellipsis',
|
||||
Boolean(shown) && shown.text.endsWith('…') && shown.text.length > 8,
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
@@ -166,7 +160,7 @@ export async function overflow(t) {
|
||||
|
||||
// 14f. The other direction: a control narrow enough to swallow its whole label draws nothing
|
||||
// at all, which the overflow walk cannot see – an empty text spills over no edge.
|
||||
for (const tab of ['scene', 'graph', 'texture', 'container', 'list', 'particles']) {
|
||||
for (const tab of ['scene', 'graph', 'texture', 'container', 'particles']) {
|
||||
await showTab(tab);
|
||||
const dropped = await json('__gm.labelsDropped()');
|
||||
check(
|
||||
@@ -183,4 +177,6 @@ export async function overflow(t) {
|
||||
onVariables.length === 0,
|
||||
JSON.stringify(onVariables),
|
||||
);
|
||||
for (const doc of (await json('__gm.documents()')).docs)
|
||||
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||||
}
|
||||
@@ -211,17 +211,17 @@ export async function containerTab(t) {
|
||||
JSON.stringify(await json('__gm.hierarchyOrder()')),
|
||||
);
|
||||
|
||||
// 16i. Switching to another container is a plain asset selection – the same thing the header's
|
||||
// picker does. The hierarchy has to follow it, title and rows both.
|
||||
// 16i. Opening another container shows it in a tab of its own (#171); the hierarchy has to
|
||||
// follow it, title and rows both.
|
||||
await showTab('container');
|
||||
await mutate(`const src = __gm.state.project.assets.find(a => a.id === '${containerId}');
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
copy.id = 'c2'; copy.name = 'Spare box';
|
||||
copy.objects = copy.objects.slice(0, 1).map((o) => ({ ...o, id: 'c2o1', parentId: '' }));
|
||||
__gm.state.project.assets.push(copy)`);
|
||||
await select({ kind: 'asset', assetId: 'c2' });
|
||||
await evaluate("__gm.state.openAsset('c2')");
|
||||
check(
|
||||
'picking another container moves the tab and the hierarchy to it',
|
||||
'opening another container moves the tab and the hierarchy to it',
|
||||
(await json('__gm.container.current()')) === 'c2' &&
|
||||
JSON.stringify(await json('__gm.hierarchyOrder()')) === JSON.stringify(['c2o1']) &&
|
||||
Boolean(await evaluate("__gm.locate('CONTAINER: SPARE BOX')")),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { edge, node, port } from '../lib/builders.mjs';
|
||||
import { MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
import { clickDocumentTab, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* A behavior used as a node inside another behavior's graph: the ports it grows from the other
|
||||
@@ -321,31 +321,36 @@ export async function nestedBehavior(t) {
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
|
||||
// Opening another behavior any other way ends the trail: the asset panel, then the picker.
|
||||
// Another behavior opened any other way gets a tab of its own (#171), without a trail; the trail
|
||||
// stays with the tab it was made in, and comes back with it.
|
||||
const enterDouble = async () => {
|
||||
const call = await json(`__gm.graph.nodeAt('nb-call')`);
|
||||
await t.doubleClick(call.x, call.y);
|
||||
};
|
||||
await enterDouble();
|
||||
const followed = await json('__gm.documents()');
|
||||
check(
|
||||
'following a call stays in the same tab',
|
||||
followed.active === 'asset:nb-double' && !followed.docs.includes('asset:nb-caller'),
|
||||
JSON.stringify(followed),
|
||||
);
|
||||
const emptyAgain = await json(`__gm.assetRow('nb-empty')`);
|
||||
await click(emptyAgain.x, emptyAgain.y);
|
||||
await t.doubleClick(emptyAgain.x, emptyAgain.y);
|
||||
check(
|
||||
'choosing another behavior in the asset panel ends the trail',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
'another behavior opened from the asset panel comes up in its own tab, without a trail',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' &&
|
||||
(await crumbs()).length === 0 &&
|
||||
(await json('__gm.documents()')).docs.includes('asset:nb-double'),
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()} docs=${JSON.stringify(await json('__gm.documents()'))}`,
|
||||
);
|
||||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'nb-caller' })`);
|
||||
await sleep(200);
|
||||
await enterDouble();
|
||||
const picker = await json('__gm.graph.pickerAt()');
|
||||
await click(picker.x, picker.y);
|
||||
const emptyItem = await locate('Empty', true);
|
||||
await click(emptyItem.x, emptyItem.y);
|
||||
await clickDocumentTab(t, 'asset:nb-double');
|
||||
check(
|
||||
'choosing another behavior in the picker ends the trail',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-empty' && (await crumbs()).length === 0,
|
||||
'back in the tab the call was followed in, its trail is there again',
|
||||
(await evaluate('__gm.graph.current()')) === 'nb-double' && (await trail()) === 'Caller › Double*',
|
||||
`current=${await evaluate('__gm.graph.current()')} trail=${await trail()}`,
|
||||
);
|
||||
await evaluate(`__gm.closeDocument('asset:nb-empty')`);
|
||||
await evaluate(`__gm.closeDocument('asset:nb-double')`);
|
||||
|
||||
// 19l. Clean up: the three behaviors and the box are gone again.
|
||||
await evaluate(`__gm.state.select(null)`);
|
||||
|
||||
@@ -103,7 +103,12 @@ export async function listTab(t) {
|
||||
|
||||
// 21e. Back on the scene tab the button carries the current count.
|
||||
await mutate('__gm.state.openList(null)');
|
||||
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
|
||||
check(
|
||||
'closing the list closes its tab',
|
||||
(await json('__gm.tab()')) !== 'list' && !(await json('__gm.documents()')).docs.includes('list'),
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), docs: await json('__gm.documents()') }),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
__gm.state.project.assets.find((a) => a.id === 'listy').props.nums = [1, 2];
|
||||
__gm.state.preview();
|
||||
|
||||
@@ -33,14 +33,16 @@ export async function plugins(t) {
|
||||
JSON.stringify(records.filter((r) => r.status !== 'active')),
|
||||
);
|
||||
|
||||
// 38b. Its tab sits after every built-in one – in the row or behind », depending on the width.
|
||||
const row = await json('__gm.tabRow()');
|
||||
const order = [...row.shown.map((p) => p.tab), ...row.hidden];
|
||||
check('the plugin tab is in the tab row', order.includes('plugin:assets'), JSON.stringify(order));
|
||||
// 38b. Its tab sits after the fixed ones and ahead of the open documents (#171) – in the row or
|
||||
// behind », depending on the width.
|
||||
const { order } = await json('__gm.tabRow()');
|
||||
const at = order.indexOf('plugin:assets');
|
||||
check('the plugin tab is in the tab row', at >= 0, JSON.stringify(order));
|
||||
check(
|
||||
'and it comes after the built-in tabs',
|
||||
row.hidden.length > 0 ? row.hidden.at(-1) === 'plugin:assets' : row.shown.at(-1)?.tab === 'plugin:assets',
|
||||
JSON.stringify(row),
|
||||
'and it comes after the fixed tabs, ahead of any document',
|
||||
JSON.stringify(order.slice(0, 3)) === JSON.stringify(['scene', 'game', 'plugins']) &&
|
||||
order.slice(at + 1).every((tab) => tab === 'list' || tab.startsWith('asset:')),
|
||||
JSON.stringify(order),
|
||||
);
|
||||
|
||||
// 38c. One node per asset, titled by its name, in the order of the asset list.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { clickDocumentTab } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Tabs as open documents (#171): a double-click in the asset panel opens a tab per asset, several of
|
||||
* one kind at once; the tab keeps its document whatever is selected, and its zoom when another tab
|
||||
* was on screen; the close cross and a middle click close it, a rename renames it and a deleted
|
||||
* asset takes its tab along.
|
||||
*/
|
||||
export async function documents(t) {
|
||||
const { check, json, evaluate, mutate, click, doubleClick, wheel, shot, showTab } = t;
|
||||
await showTab('scene');
|
||||
for (const doc of (await json('__gm.documents()')).docs)
|
||||
await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
|
||||
|
||||
// Short names of their own: an earlier step leaves them too long for a tab to show whole.
|
||||
const [a, b] = await json(`(() => {
|
||||
const two = __gm.state.project.assets.filter((x) => x.type === 'texture' && x.width > 0).slice(0, 2);
|
||||
const out = two.map((x, i) => ({ id: x.id, name: 'Sheet ' + 'AB'[i], was: x.name }));
|
||||
two.forEach((x, i) => (x.name = out[i].name));
|
||||
__gm.state.preview();
|
||||
return out;
|
||||
})()`);
|
||||
const behavior = await json(
|
||||
`(() => { const x = __gm.state.project.assets.find((x) => x.type === 'behavior' && x.graph.nodes.length > 0); return { id: x.id, node: x.graph.nodes[0].id }; })()`,
|
||||
);
|
||||
const docs = () => json('__gm.documents()');
|
||||
const current = () => json('__gm.texture.current()');
|
||||
const place = async (doc) => (await json('__gm.tabRow()')).shown.find((p) => p.tab === doc);
|
||||
|
||||
// 44a. Two textures, two tabs; each names its kind in front of the name.
|
||||
for (const texture of [a, b]) {
|
||||
const row = await json(`__gm.assetRow(${JSON.stringify(texture.id)})`);
|
||||
await doubleClick(row.x, row.y);
|
||||
}
|
||||
const opened = await docs();
|
||||
check(
|
||||
'double-clicking two textures opens a tab for each, the second one showing',
|
||||
JSON.stringify(opened.docs) === JSON.stringify([`asset:${a.id}`, `asset:${b.id}`]) &&
|
||||
opened.active === `asset:${b.id}` &&
|
||||
(await current()) === b.id,
|
||||
JSON.stringify({ opened, current: await current() }),
|
||||
);
|
||||
const placeA = await place(`asset:${a.id}`);
|
||||
const nameHits = await json(`__gm.locateAll(${JSON.stringify(a.name)})`);
|
||||
check(
|
||||
'a tab reads its kind, then the asset’s name',
|
||||
placeA?.kind === 'Texture' &&
|
||||
nameHits.some(
|
||||
(p) => p.x > placeA.x && p.x < placeA.right && p.y > placeA.y && p.y < placeA.y + placeA.h,
|
||||
),
|
||||
JSON.stringify({ placeA, nameHits }),
|
||||
);
|
||||
|
||||
// 44b. Selecting the other texture is a look at the inspector, not a change of document.
|
||||
await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(a.id)} })`);
|
||||
check('selecting the other texture leaves the tab on its own', (await current()) === b.id, await current());
|
||||
|
||||
// 44c. Zoom and scroll stay with the document.
|
||||
const view = () =>
|
||||
json(
|
||||
`(() => { const o = __gm.texture.pointAt(0, 0); const p = __gm.texture.pointAt(10, 0); return { x: o.x, y: o.y, scale: (p.x - o.x) / 10 }; })()`,
|
||||
);
|
||||
const fitted = await view();
|
||||
const rect = await json('__gm.texture.rect()');
|
||||
await wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, -300);
|
||||
const zoomed = await view();
|
||||
check('the wheel zooms the texture', zoomed.scale > fitted.scale * 1.2, JSON.stringify({ fitted, zoomed }));
|
||||
await clickDocumentTab(t, `asset:${a.id}`);
|
||||
check('its neighbour’s tab shows the other texture', (await current()) === a.id, await current());
|
||||
await clickDocumentTab(t, `asset:${b.id}`);
|
||||
const back = await view();
|
||||
check(
|
||||
'coming back, the texture is zoomed and scrolled as it was left',
|
||||
(await current()) === b.id &&
|
||||
Math.abs(back.scale - zoomed.scale) < 1e-6 &&
|
||||
Math.abs(back.x - zoomed.x) < 0.5 &&
|
||||
Math.abs(back.y - zoomed.y) < 0.5,
|
||||
JSON.stringify({ zoomed, back }),
|
||||
);
|
||||
|
||||
// 44d. A rename renames the tab.
|
||||
await mutate(
|
||||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = 'Renamed sheet'; __gm.state.preview();`,
|
||||
);
|
||||
const renamedAt = await place(`asset:${b.id}`);
|
||||
const renamedHits = await json(`__gm.locateAll('Renamed sheet')`);
|
||||
check(
|
||||
'renaming the asset renames its tab',
|
||||
renamedHits.some(
|
||||
(p) =>
|
||||
p.x > renamedAt.x && p.x < renamedAt.right && p.y > renamedAt.y && p.y < renamedAt.y + renamedAt.h,
|
||||
),
|
||||
JSON.stringify({ renamedAt, renamedHits }),
|
||||
);
|
||||
await mutate(
|
||||
`__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(b.id)}).name = ${JSON.stringify(b.name)}; __gm.state.preview();`,
|
||||
);
|
||||
|
||||
// 44e. A node lives only in its graph: selecting one brings that graph up, in a tab of its own.
|
||||
await evaluate(
|
||||
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(behavior.id)}, nodeId: ${JSON.stringify(behavior.node)} })`,
|
||||
);
|
||||
const withGraph = await docs();
|
||||
check(
|
||||
'selecting a node opens its behavior next to the texture showing',
|
||||
withGraph.active === `asset:${behavior.id}` &&
|
||||
JSON.stringify(withGraph.docs) ===
|
||||
JSON.stringify([`asset:${a.id}`, `asset:${b.id}`, `asset:${behavior.id}`]) &&
|
||||
(await json('__gm.tab()')) === 'graph',
|
||||
JSON.stringify(withGraph),
|
||||
);
|
||||
await shot('44-documents');
|
||||
|
||||
// 44f. The close cross closes the tab on screen; its left neighbour comes up.
|
||||
const graphTab = await place(`asset:${behavior.id}`);
|
||||
await click(graphTab.close.x, graphTab.close.y);
|
||||
const afterClose = await docs();
|
||||
check(
|
||||
'the close cross closes the tab, and the one left of it shows',
|
||||
!afterClose.docs.includes(`asset:${behavior.id}`) &&
|
||||
afterClose.active === `asset:${b.id}` &&
|
||||
(await current()) === b.id,
|
||||
JSON.stringify(afterClose),
|
||||
);
|
||||
|
||||
// 44g. A middle click closes a tab that is not showing, and leaves the one that is.
|
||||
const tabA = await place(`asset:${a.id}`);
|
||||
await click((tabA.x + tabA.close.x) / 2, tabA.y + tabA.h / 2, 'middle');
|
||||
const afterMiddle = await docs();
|
||||
check(
|
||||
'a middle click closes a tab in the background',
|
||||
JSON.stringify(afterMiddle.docs) === JSON.stringify([`asset:${b.id}`]) &&
|
||||
afterMiddle.active === `asset:${b.id}`,
|
||||
JSON.stringify(afterMiddle),
|
||||
);
|
||||
|
||||
// 44h. A deleted asset takes its tab along, and the last one gone leaves the Scene tab showing.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
window.__deletedSheet = p.assets.find((x) => x.id === ${JSON.stringify(b.id)});
|
||||
p.assets = p.assets.filter((x) => x.id !== ${JSON.stringify(b.id)});
|
||||
__gm.state.preview();
|
||||
`);
|
||||
const afterDelete = await docs();
|
||||
check(
|
||||
'deleting the asset closes its tab and goes back to the scene',
|
||||
afterDelete.docs.length === 0 && (await json('__gm.tab()')) === 'scene',
|
||||
JSON.stringify({ afterDelete, tab: await json('__gm.tab()') }),
|
||||
);
|
||||
await mutate(
|
||||
`__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`,
|
||||
);
|
||||
await mutate(`
|
||||
for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was;
|
||||
__gm.state.preview();
|
||||
`);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { tilemap } from './40-tilemap.mjs';
|
||||
import { physics } from './41-physics.mjs';
|
||||
import { shapes } from './42-shapes.mjs';
|
||||
import { queries } from './43-queries.mjs';
|
||||
import { documents } from './44-documents.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -91,4 +92,5 @@ export const STEPS = [
|
||||
physics,
|
||||
shapes,
|
||||
queries,
|
||||
documents,
|
||||
];
|
||||
@@ -128,6 +128,11 @@ export const TAB_LABELS = {
|
||||
* nothing and took the whole suite down with it.
|
||||
*/
|
||||
export async function openTab(t, tab) {
|
||||
// A kind of document has no tab of its own since #171; the hook shows the one the selection is about.
|
||||
if (DOCUMENT_KINDS.includes(tab)) {
|
||||
await t.evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
|
||||
return;
|
||||
}
|
||||
const label = TAB_LABELS[tab];
|
||||
const row = await t.json('__gm.tabRow()');
|
||||
if (row.hidden.includes(tab)) {
|
||||
@@ -148,6 +153,42 @@ export async function openTab(t, tab) {
|
||||
else await t.clickText(label);
|
||||
}
|
||||
|
||||
const DOCUMENT_KINDS = [
|
||||
'graph',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'particles',
|
||||
'texture',
|
||||
'animation',
|
||||
'tile',
|
||||
'container',
|
||||
'list',
|
||||
];
|
||||
|
||||
/**
|
||||
* Clicks the tab of an open document (`asset:<id>` or `list`), through the `»` menu when the row
|
||||
* hides it. The press lands left of the ×, on the name.
|
||||
*/
|
||||
export async function clickDocumentTab(t, doc) {
|
||||
const row = await t.json('__gm.tabRow()');
|
||||
const entry = row.shown.find((e) => e.tab === doc);
|
||||
if (entry) {
|
||||
await t.click(entry.x + (entry.close.x - entry.x) / 2, entry.y + entry.h / 2);
|
||||
return;
|
||||
}
|
||||
if (!row.hidden.includes(doc)) throw new Error(`no tab for ${doc}`);
|
||||
const label = await t.json(`(() => {
|
||||
const id = ${JSON.stringify(doc)};
|
||||
if (id === 'list') return null;
|
||||
const asset = __gm.state.project.assets.find((a) => 'asset:' + a.id === id);
|
||||
return asset ? asset.name : null;
|
||||
})()`);
|
||||
await t.clickText('»');
|
||||
const item = (await t.json('__gm.menuRows()')).find((r) => r && r.endsWith(` · ${label}`));
|
||||
const at = await t.locate(item, true);
|
||||
await t.click(at.x, at.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an asset type from the already-open `+` menu. Since #43 the menu may grow to the height of
|
||||
* the canvas, so on this window every type is usually on screen at once; the scroll stays in as
|
||||
|
||||
Reference in new issue
Block a user