diff --git a/README.md b/README.md index 58a28ca9..5babe1ae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index ab111f4c..b701c674 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -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: 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(); 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); - } } diff --git a/src/editor/DocumentTab.ts b/src/editor/DocumentTab.ts new file mode 100644 index 00000000..49236c00 --- /dev/null +++ b/src/editor/DocumentTab.ts @@ -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; + } +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index f02ef711..ba9f35cc 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; + +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 = { scene: 'Scene', game: 'Game', @@ -109,6 +125,10 @@ const TAB_OF_ASSET: Partial> = { 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; + private readonly tabs: TabStrip; private readonly panes: Record; + private readonly assetEditors: Record>; + private documents: DocumentRow = { docs: [], active: null }; private readonly pluginPanes = new Map(); /** 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( - TAB_ORDER.map((tab) => ({ tab, label: TAB_LABELS[tab] })), - (tab) => this.showTab(tab), + this.tabs = new TabStrip( + 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` 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(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[]; hidden: TabId[] } { - return { shown: this.tabs.places(), hidden: this.tabs.hidden() }; + tabRow(): { shown: TabPlace[]; hidden: StripTab[]; order: StripTab[] } { + return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] }; } boot(): Promise { diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index a4afd108..c4d02d65 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -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 { /** 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 { */ export class TabStrip extends HStack { private readonly order: T[] = []; - private readonly labels = new Map(); private readonly buttons = new Map(); 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 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 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 = { 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 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); } } diff --git a/src/editor/animation/AnimationEditor.ts b/src/editor/animation/AnimationEditor.ts index 714eefae..52f9054d 100644 --- a/src/editor/animation/AnimationEditor.ts +++ b/src/editor/animation/AnimationEditor.ts @@ -26,7 +26,7 @@ export class AnimationEditor extends AssetTabEditor 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 { 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 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:` 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 ?? [])], diff --git a/src/editor/documents.ts b/src/editor/documents.ts new file mode 100644 index 00000000..88d1f141 --- /dev/null +++ b/src/editor/documents.ts @@ -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 { + 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(row: DocumentRow, doc: T): DocumentRow { + 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(row: DocumentRow, doc: T): DocumentRow { + 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(row: DocumentRow, from: T, to: T): DocumentRow { + 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 }; +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 5fe0fde8..5740b02e 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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 }; diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 2a38e6b9..5a1fb4b6 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -14,9 +14,11 @@ export class GraphEditor extends AssetTabEditor { 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 { * 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 { 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'; } diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index cf351114..daa7d283 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -42,7 +42,7 @@ export class ParticlesEditor extends AssetTabEditor 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 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'; } diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index b5c17c0c..7032a80c 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -17,17 +17,17 @@ export class ShaderEditor extends AssetTabEditor { 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'; } diff --git a/src/editor/statemachine/StateMachineEditor.ts b/src/editor/statemachine/StateMachineEditor.ts index b37475eb..a6a7f417 100644 --- a/src/editor/statemachine/StateMachineEditor.ts +++ b/src/editor/statemachine/StateMachineEditor.ts @@ -17,17 +17,17 @@ export class StateMachineEditor extends AssetTabEditor 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'; } diff --git a/src/editor/tabRow.ts b/src/editor/tabRow.ts index 37374b91..c8a212aa 100644 --- a/src/editor/tabRow.ts +++ b/src/editor/tabRow.ts @@ -10,7 +10,7 @@ export interface TabRowInput { 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 { diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index f150d090..e3b79cb3 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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; diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index 23b481f1..7a259907 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -15,7 +15,7 @@ export class TextureEditor extends AssetTabEditor { 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 { 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'; } diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 250e324f..ef689bf3 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -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; diff --git a/src/editor/tile/TileEditor.ts b/src/editor/tile/TileEditor.ts index 45546f72..b5f363e8 100644 --- a/src/editor/tile/TileEditor.ts +++ b/src/editor/tile/TileEditor.ts @@ -25,7 +25,7 @@ export class TileEditor extends AssetTabEditor { 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 { 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'; } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 1d1a2f99..592ec8f7 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -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 }; } diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index b11ed54f..8f4db737 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -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; protected readonly align: NonNullable; protected readonly fixedWidth: number; - private readonly padding: number; + protected readonly padding: number; private readonly style: TextStyleToken; private readonly toggle: boolean; onClick?: () => void; diff --git a/tests/editor/documents.test.ts b/tests/editor/documents.test.ts new file mode 100644 index 00000000..b0e853ef --- /dev/null +++ b/tests/editor/documents.test.ts @@ -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'] }); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index c78e8c21..1eda95e2 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -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. diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 40d5362f..24801e01 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -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)})`); } diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs index d0fd8bd8..cba8ea2c 100644 --- a/tools/e2e/16-container.mjs +++ b/tools/e2e/16-container.mjs @@ -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')")), diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index c14c3ba5..b0defca2 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -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)`); diff --git a/tools/e2e/21-list.mjs b/tools/e2e/21-list.mjs index 76e55373..070b8012 100644 --- a/tools/e2e/21-list.mjs +++ b/tools/e2e/21-list.mjs @@ -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(); diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs index b1d86d6b..61168dec 100644 --- a/tools/e2e/38-plugins.mjs +++ b/tools/e2e/38-plugins.mjs @@ -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. diff --git a/tools/e2e/44-documents.mjs b/tools/e2e/44-documents.mjs new file mode 100644 index 00000000..3e743b9c --- /dev/null +++ b/tools/e2e/44-documents.mjs @@ -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(); + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 18884150..09e64de7 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 4eef4a49..541e3e8b 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -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:` 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