Float a document's tab in a window of its own, and dock it (#371)
game-maker / gate (push) Successful in 3m54s
game-maker / suite (push) Successful in 15m17s

The Parametric EQ has a tab of its own since #370, but Play is in the
sound FX's tab. A tab that can float keeps both in view. There is one
editor per kind, so the kind's pane is lent to the window and given
back on Dock, the way fullscreen moves the game view.

- A right-click on a document's tab opens a menu with Float and Close.
  `DocumentTab` passes the press to `TabStrip`'s new `onMenu`.
- `FloatWindow` sits on the overlay. Its title bar moves it, its lower
  right corner sizes it, Dock and the x close it. A press brings it to
  the front.
- `floating.ts` is the pure part: where a window opens, held whole on
  the screen, sized down to 320 x 200. Nothing of it is remembered.
- One window per kind. Another document of that kind opens there and
  closes the one it had. Its tab, if it had one, leaves the row.
- When the row showed the kind, it shows the nearest document whose
  kind is not in a window, else the scene (`neighbourOf`).
- Delete, Copy and Paste go to the window pressed last, and back to
  the tab showing after a press in the editor (`keyTab`).
- Previews tick and redraw for a kind in a window as for the tab
  showing. Fullscreen hides the windows. Loading a project or signing
  out closes them.
- Only documents float, Scene and Game stay in the row.
- Hooks: `floatWindows`, `floatDocument`, `keyTarget`.

Tests:
- Unit documents: 16 green, 5 new. Counter-check: `neighbourOf`
  without the window check turns its test red.
- e2e 44 (melektaus-atx, after steps 1-12 and 37): ALL PASSED,
  505 checks in the chain, 6 new for float, move, size, another
  texture, keys, Dock and x.
- Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4236 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 14:27:12 +02:00
1 parent 9166d4a56f
commit 76f5097a38
9 files changed
+642 -34

No files matched your search

+7 -1
View File
@@ -11,7 +11,8 @@ const MAX_NAME_WIDTH = 160;
/** /**
* A tab that stands for an open document (#171): the icon of its kind before the name, so three * A tab that stands for an open document (#171): the icon of its kind before the name, so three
* assets called "Player" can be told apart, and an × that closes it – as does a middle click. * assets called "Player" can be told apart, and an × that closes it – as does a middle click. A
* right-click opens what else can be done with it (#371).
*/ */
export class DocumentTab extends TabButton { export class DocumentTab extends TabButton {
private readonly closeText: Text; private readonly closeText: Text;
@@ -22,8 +23,13 @@ export class DocumentTab extends TabButton {
icon: string, icon: string,
onPick: () => void, onPick: () => void,
private readonly onClose: () => void, private readonly onClose: () => void,
onMenu?: (globalX: number, globalY: number) => void,
) { ) {
super(label, { align: 'left', icon, onClick: onPick }); super(label, { align: 'left', icon, onClick: onPick });
// On the press, as a row of the asset panel opens its menu.
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 2) onMenu?.(e.global.x, e.global.y);
});
this.closeText = crispText({ text: CLOSE, style: textStyle('muted') }); this.closeText = crispText({ text: CLOSE, style: textStyle('muted') });
this.closeText.eventMode = 'static'; this.closeText.eventMode = 'static';
this.closeText.cursor = Theme.hoverCursor; this.closeText.cursor = Theme.hoverCursor;
+235 -31
View File
@@ -31,7 +31,9 @@ import {
} from '../ui'; } from '../ui';
import { TabStrip, type TabPlace } from './TabStrip'; import { TabStrip, type TabPlace } from './TabStrip';
import type { AssetTabEditor } from './AssetTabEditor'; import type { AssetTabEditor } from './AssetTabEditor';
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents'; import { closeDocument, type DocumentRow, neighbourOf, openDocument, replaceDocument } from './documents';
import { openingBox, type WindowBox } from './floating';
import { FloatWindow } from './FloatWindow';
import { ConsolePanel } from './console/ConsolePanel'; import { ConsolePanel } from './console/ConsolePanel';
import { GamePane } from './viewport/GamePane'; import { GamePane } from './viewport/GamePane';
import { ScenePane } from './viewport/ScenePane'; import { ScenePane } from './viewport/ScenePane';
@@ -299,6 +301,13 @@ export class Editor implements PlayHost {
private readonly panes: Record<CentreTab, Widget>; private readonly panes: Record<CentreTab, Widget>;
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>; private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
private documents: DocumentRow<DocumentId> = { docs: [], active: null }; private documents: DocumentRow<DocumentId> = { docs: [], active: null };
/**
* The kinds whose pane is in a window of its own (#371), and the document each window shows. A
* kind has one editor, so it has one window: another document of it opens there, not in a tab.
*/
private readonly floats = new Map<CentreTab, { doc: DocumentId; window: FloatWindow }>();
/** The window the last press went into; null when it went into the editor. Keys follow it. */
private pressedWindow: CentreTab | null = null;
private readonly pluginPanes = new Map<PluginTabId, Widget>(); private readonly pluginPanes = new Map<PluginTabId, Widget>();
/** The plugins of this editor and their switches; null until `startPlugins` has run. */ /** The plugins of this editor and their switches; null until `startPlugins` has run. */
plugins: PluginManager | null = null; plugins: PluginManager | null = null;
@@ -482,6 +491,11 @@ export class Editor implements PlayHost {
}; };
// Following a call goes on in the same tab: the tab changes its document, not its place. // Following a call goes on in the same tab: the tab changes its document, not its place.
this.graphEditor.onRetarget = (from, to) => { this.graphEditor.onRetarget = (from, to) => {
const float = this.floats.get('graph');
if (float?.doc === `asset:${from}`) {
this.showInWindow('graph', `asset:${to}`);
return;
}
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`); const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
if (next === this.documents) return; if (next === this.documents) return;
this.documents = next; this.documents = next;
@@ -527,6 +541,8 @@ export class Editor implements PlayHost {
this.workspace.add(this.inspector); this.workspace.add(this.inspector);
root.add(this.workspace, 1); root.add(this.workspace, 1);
root.resize(app.screen.width, app.screen.height); root.resize(app.screen.width, app.screen.height);
// A press in the editor takes the keys back from a window (#371).
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
this.autosave = attachAutosave(state, store); this.autosave = attachAutosave(state, store);
this.cleanups.push( this.cleanups.push(
@@ -535,9 +551,9 @@ export class Editor implements PlayHost {
stop: () => this.play.stop(), stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(), togglePause: () => this.play.togglePause(),
copy: () => copy: () =>
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || (this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) ||
(this.tab === 'pattern' && this.patternEditor.canvas.copy()) || (this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) ||
(this.tab === 'song' && this.songEditor.canvas.copy()) || (this.keyTab() === 'song' && this.songEditor.canvas.copy()) ||
copySelection(state), copySelection(state),
paste: () => this.paste(), paste: () => this.paste(),
removePoint: () => this.removePoint(), removePoint: () => this.removePoint(),
@@ -597,6 +613,7 @@ export class Editor implements PlayHost {
state.on('load', () => { state.on('load', () => {
syncMix(); syncMix();
this.editorRun.invalidate(); this.editorRun.invalidate();
for (const kind of [...this.floats.keys()]) this.closeWindow(kind);
for (const doc of this.documents.docs) this.tabs.removeTab(doc); for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null }; this.documents = { docs: [], active: null };
this.valueTargets.clear(); this.valueTargets.clear();
@@ -635,13 +652,25 @@ export class Editor implements PlayHost {
private showsPick(): boolean { private showsPick(): boolean {
const pick = this.state.objectPick; const pick = this.state.objectPick;
if (!pick) return false; if (!pick) return false;
if (this.tab === 'container') return this.containerEditor.currentAssetId === pick.holderId; if (this.shows('container') && this.containerEditor.currentAssetId === pick.holderId) return true;
return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene'; return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene';
} }
/**
* Where Delete, Copy and Paste go: the window pressed last (#371), else the tab showing.
*/
private keyTab(): TabId {
return this.pressedWindow && this.floats.has(this.pressedWindow) ? this.pressedWindow : this.tab;
}
/** Whether a kind's pane is on screen, in the tab showing or in a window of its own. */
private shows(kind: CentreTab): boolean {
return this.tab === kind || this.floats.has(kind);
}
/** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */ /** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */
private removePoint(): boolean { private removePoint(): boolean {
switch (this.tab) { switch (this.keyTab()) {
case 'scene': case 'scene':
return this.viewport.removePickedCorner(); return this.viewport.removePickedCorner();
case 'container': case 'container':
@@ -667,7 +696,7 @@ export class Editor implements PlayHost {
/** Ctrl+V lands in whatever the visible tab edits (#240). */ /** Ctrl+V lands in whatever the visible tab edits (#240). */
private paste(): boolean { private paste(): boolean {
switch (this.tab) { switch (this.keyTab()) {
case 'animation': case 'animation':
return this.animationEditor.canvas.pasteKeys(); return this.animationEditor.canvas.pasteKeys();
case 'scene': case 'scene':
@@ -810,7 +839,12 @@ export class Editor implements PlayHost {
/** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */ /** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */
openDocument(doc: DocumentId): void { openDocument(doc: DocumentId): void {
if (!this.kindOf(doc)) return; const kind = this.kindOf(doc);
if (!kind) return;
if (this.floats.has(kind)) {
this.showInWindow(kind, doc);
return;
}
if (this.documents.active === doc) { if (this.documents.active === doc) {
// Still worth a look at the name: a Variables tab's owner may have been renamed. // Still worth a look at the name: a Variables tab's owner may have been renamed.
this.syncStrip(); this.syncStrip();
@@ -823,21 +857,174 @@ export class Editor implements PlayHost {
/** Closes a document's tab; nothing is lost, every edit is in the model already. */ /** Closes a document's tab; nothing is lost, every edit is in the model already. */
closeDocument(doc: DocumentId): void { closeDocument(doc: DocumentId): void {
const floating = [...this.floats].find(([, float]) => float.doc === doc)?.[0];
if (floating) {
this.closeWindow(floating);
return;
}
if (!this.documents.docs.includes(doc)) return; if (!this.documents.docs.includes(doc)) return;
const wasShowing = this.documents.active === doc; const wasShowing = this.documents.active === doc;
const at = this.documents.docs.indexOf(doc);
this.leaveRow(doc);
this.forget(doc);
if (wasShowing) this.showBeside(at);
}
/** Takes a document out of the tab row, without anything else that closing it does. */
private leaveRow(doc: DocumentId): void {
this.documents = closeDocument(this.documents, doc); this.documents = closeDocument(this.documents, doc);
this.tabs.removeTab(doc); this.tabs.removeTab(doc);
}
/** Lets go of what a document kept besides its tab: its editor's view, what its tab was for. */
private forget(doc: DocumentId): void {
const kind = this.kindOf(doc); const kind = this.kindOf(doc);
if (kind && kind in this.assetEditors) if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc); this.valueTargets.delete(doc);
if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close(); if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close();
if (doc === 'routing') this.routingEditor.close(); if (doc === 'routing') this.routingEditor.close();
if (!wasShowing) return; }
if (this.documents.active) this.showDocument(this.documents.active);
/**
* Shows what comes in place of the document that stood at `at` in the row: its left neighbour,
* else its right one, else the scene – passing over documents whose kind is in a window.
*/
private showBeside(at: number): void {
const next = neighbourOf(this.documents.docs, at, (doc) => {
const kind = this.kindOf(doc);
return kind !== null && !this.floats.has(kind);
});
if (next) this.showDocument(next);
else this.showTab('scene'); else this.showTab('scene');
} }
/**
* Moves a document out of the tab row into a window of its own (#371), its kind's pane with it.
* With a window for the kind open already, the document goes there instead.
*/
floatDocument(doc: DocumentId): void {
const kind = this.kindOf(doc);
if (!kind) return;
if (this.floats.has(kind)) {
this.showInWindow(kind, doc);
return;
}
const at = this.documents.docs.indexOf(doc);
const showing = this.documents.active;
const centreHadIt = this.tab === kind;
if (at >= 0) this.leaveRow(doc);
const window = new FloatWindow({
onDock: () => this.dockWindow(kind),
onClose: () => this.closeWindow(kind),
onPress: () => (this.pressedWindow = kind),
});
const pane = this.panes[kind];
this.centre.removeChild(pane);
window.setContent(pane);
const screen = ui().app.screen;
window.open(openingBox(screen.width, screen.height, this.floats.size));
this.floats.set(kind, { doc, window });
this.showInWindow(kind, doc);
// The tab row showed this kind – the document itself, or another one of it: the pane is gone.
if (!centreHadIt) return;
const from = showing && showing !== doc ? this.documents.docs.indexOf(showing) : at;
this.showBeside(from >= 0 ? from : this.documents.docs.length);
}
/** Puts a window's document back into the tab row, showing, and its pane back below the row. */
dockWindow(kind: CentreTab): void {
const float = this.floats.get(kind);
if (!float) return;
this.takeBack(kind);
this.documents = openDocument(this.documents, float.doc);
this.syncStrip();
this.showDocument(float.doc);
// Back at the size it had below the row, the pane would not lay itself out for the move.
this.panes[kind].layout();
this.panes[kind].redraw();
}
/** Closes a window and the document in it, as the × of its tab would. */
closeWindow(kind: CentreTab): void {
const float = this.floats.get(kind);
if (!float) return;
this.takeBack(kind);
this.forget(float.doc);
}
/** The pane comes home below the tab row, hidden until its kind shows there; the window goes. */
private takeBack(kind: CentreTab): void {
const float = this.floats.get(kind)!;
this.floats.delete(kind);
if (this.pressedWindow === kind) this.pressedWindow = null;
const pane = this.panes[kind];
pane.visible = false;
this.centre.addChildAt(pane, this.centre.getChildIndex(this.consoleSplitter));
pane.flex = 1;
float.window.close();
float.window.destroy({ children: true });
this.centre.relayout();
}
/** Points a window at `doc`: another document of its kind takes the place of the one it had. */
private showInWindow(kind: CentreTab, doc: DocumentId): void {
const float = this.floats.get(kind)!;
if (float.doc !== doc) {
const old = float.doc;
float.doc = doc;
// A document has one place: its tab goes when it comes into the window.
if (this.documents.docs.includes(doc)) this.leaveRow(doc);
this.forget(old);
}
this.pointPane(kind, doc);
float.window.setTitle(this.labelOf(doc), this.iconOf(doc));
float.window.toFront();
this.pressedWindow = kind;
}
/** The windows open, for the check scripts: what each shows, where, and where its controls are. */
floatWindows(): {
kind: CentreTab;
doc: DocumentId;
title: string;
box: WindowBox;
bar: { x: number; y: number };
grip: { x: number; y: number };
dock: { x: number; y: number };
close: { x: number; y: number };
front: boolean;
}[] {
const centre = (w: Widget) => {
const at = w.getGlobalPosition();
return { x: at.x + w.w / 2, y: at.y + w.h / 2 };
};
return [...this.floats].map(([kind, { doc, window }]) => ({
kind,
doc,
title: window.titleText,
box: window.box,
bar: window.barPoint(),
grip: window.gripPoint(),
dock: centre(window.dockButton),
close: centre(window.closeButton),
front: window.parent?.children.at(-1) === window,
}));
}
/** Where Delete, Copy and Paste go now, for the check scripts (#371). */
keyTarget(): TabId {
return this.keyTab();
}
/** A right-click on a document's tab (#371). */
private openTabMenu(doc: DocumentId, x: number, y: number): void {
new Menu([
{ label: 'Float', onSelect: () => this.floatDocument(doc) },
{ label: 'Close', onSelect: () => this.closeDocument(doc) },
]).open(x, y);
}
/** /**
* Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the * 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 * one the selection is about, else the one its editor last had – the way the check scripts reach
@@ -879,6 +1066,7 @@ export class Editor implements PlayHost {
if (on === this.gameView.isFullscreen) return; if (on === this.gameView.isFullscreen) return;
this.toolbar.visible = !on; this.toolbar.visible = !on;
this.workspace.visible = !on; this.workspace.visible = !on;
for (const { window } of this.floats.values()) window.visible = !on;
if (on) this.root.add(this.gameView, 1); if (on) this.root.add(this.gameView, 1);
else this.gamePane.add(this.gameView, 1); else this.gamePane.add(this.gameView, 1);
this.gameView.setFullscreen(on); this.gameView.setFullscreen(on);
@@ -911,6 +1099,16 @@ export class Editor implements PlayHost {
private showDocument(doc: DocumentId): void { private showDocument(doc: DocumentId): void {
const kind = this.kindOf(doc); const kind = this.kindOf(doc);
if (!kind) return; if (!kind) return;
if (this.floats.has(kind)) {
this.showInWindow(kind, doc);
return;
}
this.pointPane(kind, doc);
this.display(kind, doc);
}
/** Points the editor of a kind at a document, wherever its pane is. */
private pointPane(kind: CentreTab, doc: DocumentId): void {
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length)); if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
const target = this.valueTargets.get(doc); const target = this.valueTargets.get(doc);
if (kind === 'variables' && target) this.variablesEditor.show(target); if (kind === 'variables' && target) this.variablesEditor.show(target);
@@ -919,7 +1117,6 @@ export class Editor implements PlayHost {
if (kind === 'routing') this.routingEditor.open(); if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc); const eq = this.eqTargets.get(doc);
if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId); if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId);
this.display(kind, doc);
} }
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */ /** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
@@ -957,6 +1154,7 @@ export class Editor implements PlayHost {
kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind], kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
icon: this.iconOf(doc), icon: this.iconOf(doc),
onClose: () => this.closeDocument(doc), onClose: () => this.closeDocument(doc),
onMenu: (x, y) => this.openTabMenu(doc, x, y),
}); });
else this.tabs.setLabel(doc, this.labelOf(doc)); else this.tabs.setLabel(doc, this.labelOf(doc));
}); });
@@ -965,6 +1163,9 @@ export class Editor implements PlayHost {
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */ /** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
private syncDocuments(): void { private syncDocuments(): void {
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc); for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
for (const [kind, { doc, window }] of [...this.floats])
if (!this.kindOf(doc)) this.closeWindow(kind);
else window.setTitle(this.labelOf(doc), this.iconOf(doc));
this.syncStrip(); this.syncStrip();
} }
@@ -980,7 +1181,8 @@ export class Editor implements PlayHost {
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId) ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene, : () => this.state.activeScene,
); );
for (const key of PANE_ORDER) this.panes[key].visible = key === tab; // A pane in a window shows there whatever the row shows.
for (const key of PANE_ORDER) if (!this.floats.has(key)) this.panes[key].visible = key === tab;
for (const [key, pane] of this.pluginPanes) pane.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. // The view checks its own flag before it redraws, and the row around it is what switches now.
this.viewport.visible = tab === 'scene'; this.viewport.visible = tab === 'scene';
@@ -1047,6 +1249,8 @@ export class Editor implements PlayHost {
this.editorRun.shutDown(); this.editorRun.shutDown();
await this.plugins?.stopAll(); await this.plugins?.stopAll();
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup(); for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
// Before the overlay is emptied: a window listens to the renderer, and its pane goes home with the rest.
for (const kind of [...this.floats.keys()]) this.closeWindow(kind);
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too. // Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
this.passwordDialog.current()?.close(); this.passwordDialog.current()?.close();
this.generalDialog.current()?.close(); this.generalDialog.current()?.close();
@@ -1087,20 +1291,20 @@ export class Editor implements PlayHost {
this.viewport.animate(sceneDraws); this.viewport.animate(sceneDraws);
this.gameView.animate(gameDraws); this.gameView.animate(gameDraws);
this.console.refresh(); this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step); if (this.shows('animation')) this.animationEditor.tick(step);
if (this.tab === 'tile') this.tileEditor.tick(step); if (this.shows('tile')) this.tileEditor.tick(step);
// The preview is the editor's own, so it runs on while the game is paused. // The preview is the editor's own, so it runs on while the game is paused.
if (this.tab === 'curve') this.curveEditor.tick(dt); if (this.shows('curve')) this.curveEditor.tick(dt);
if (this.tab === 'pattern') this.patternEditor.tick(); if (this.shows('pattern')) this.patternEditor.tick();
if (this.tab === 'song') this.songEditor.tick(); if (this.shows('song')) this.songEditor.tick();
else this.songEditor.hideMeters(); else this.songEditor.hideMeters();
if (this.tab === 'sound') this.soundEditor.tick(); if (this.shows('sound')) this.soundEditor.tick();
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// to do it, and its states move just the same. // to do it, and its states move just the same.
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.tab === 'particles') this.particlesEditor.tick(); if (this.shows('particles')) this.particlesEditor.tick();
if (this.tab === 'shader') this.shaderEditor.tick(); if (this.shows('shader')) this.shaderEditor.tick();
if (this.tab === 'pixelart') this.pixelArtEditor.tick(); if (this.shows('pixelart')) this.pixelArtEditor.tick();
if (this.movesByItself()) requestRender(); if (this.movesByItself()) requestRender();
const drawn = this.gameView.drawTime - drawnBefore; const drawn = this.gameView.drawTime - drawnBefore;
if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime())) if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime()))
@@ -1122,15 +1326,15 @@ export class Editor implements PlayHost {
this.editorRun.isRunning || this.editorRun.isRunning ||
(this.viewport.visible && this.viewport.animates) || (this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) || (this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' || this.shows('particles') ||
this.tab === 'pixelart' || this.shows('pixelart') ||
(this.tab === 'shader' && this.shaderEditor.animates) || (this.shows('shader') && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) || (this.shows('animation') && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying) || (this.shows('tile') && this.tileEditor.isPlaying) ||
(this.tab === 'curve' && this.curveEditor.isPlaying) || (this.shows('curve') && this.curveEditor.isPlaying) ||
(this.tab === 'pattern' && this.patternEditor.isPlaying) || (this.shows('pattern') && this.patternEditor.isPlaying) ||
(this.tab === 'song' && this.songEditor.isPlaying) || (this.shows('song') && this.songEditor.isPlaying) ||
(this.tab === 'sound' && this.soundEditor.isPlaying) (this.shows('sound') && this.soundEditor.isPlaying)
); );
} }
+187
View File
@@ -0,0 +1,187 @@
import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
import { Button, crisp, drawControlBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui';
import { onScreen, sized, type WindowBox } from './floating';
import { TabHeader } from './TabHeader';
/** The corner taken hold of to size the window. */
const GRIP = 14;
export interface FloatWindowHost {
onDock: () => void;
onClose: () => void;
/** Any press inside the window, before what is under the pointer sees it. */
onPress: () => void;
}
/**
* A tab's pane in a window of its own (#371), above the editor. The pane is lent, not owned: the
* editor puts it in with `setContent` and takes it back before the window goes. The title bar
* moves the window, the lower right corner sizes it, and neither is remembered.
*/
export class FloatWindow extends Widget {
private readonly bg = new Graphics();
private readonly bar = new TabHeader();
private icon: { svg: string; widget: Icon } | null = null;
private readonly title = new Label('', { style: 'bodyBold' });
readonly dockButton: Button;
readonly closeButton: Button;
private readonly grip = crisp(new Graphics());
private content: Widget | null = null;
private gesture: { kind: 'move' | 'size'; x: number; y: number; box: WindowBox } | null = null;
constructor(host: FloatWindowHost) {
super();
this.dockButton = new Button('Dock', {
variant: 'ghost',
tooltip: 'Back into the tab row',
onClick: () => host.onDock(),
});
this.closeButton = new Button('×', { variant: 'ghost', tooltip: 'Close', onClick: () => host.onClose() });
this.bar.add(this.title);
this.bar.add(new Spacer(), 1);
this.bar.add(this.dockButton);
this.bar.add(this.closeButton);
this.bar.cursor = 'move';
this.bar.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 0 && !onButton(e.target, this.bar)) this.grab('move', e);
});
this.grip.eventMode = 'static';
this.grip.cursor = 'nwse-resize';
this.grip.hitArea = new Rectangle(0, 0, GRIP, GRIP);
this.grip.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 0) this.grab('size', e);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => this.drag(e));
this.onPointerRelease(() => (this.gesture = null));
// Before anything inside: the window comes up, and Delete or Ctrl+V go to it from now on.
this.on('pointerdowncapture', () => {
this.toFront();
host.onPress();
});
this.addChild(this.bg, this.bar, this.grip);
}
get isOpen(): boolean {
return this.parent !== null;
}
/** Where the window is and how large, for the check scripts. */
get box(): WindowBox {
return { x: this.x, y: this.y, w: this.w, h: this.h };
}
/** Where the title bar can be taken hold of: left of the buttons, clear of the name. */
barPoint(): { x: number; y: number } {
const at = this.bar.getGlobalPosition();
return { x: at.x + this.dockButton.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
}
/** The middle of the sizing corner, for the check scripts. */
gripPoint(): { x: number; y: number } {
const at = this.grip.getGlobalPosition();
return { x: at.x + GRIP / 2, y: at.y + GRIP / 2 };
}
get titleText(): string {
return this.title.text;
}
/** The pane to show; the one before is left without a parent for the caller to put back. */
setContent(content: Widget): void {
if (this.content?.parent === this) this.removeChild(this.content);
this.content = content;
content.visible = true;
this.addChildAt(content, this.getChildIndex(this.bar));
this.layout();
}
/** The name and the icon of the document shown, as its tab had them. */
setTitle(text: string, svg: string): void {
if (this.icon?.svg !== svg) {
if (this.icon) this.icon.widget.destroy();
this.icon = { svg, widget: new Icon(svg) };
this.bar.addChildAt(this.icon.widget, this.bar.getChildIndex(this.title));
}
if (this.title.text === text) return;
this.title.setText(text);
this.bar.relayout();
}
open(box: WindowBox): void {
ui().overlay.addChild(this);
ui().app.renderer.on('resize', this.keepOnScreen);
this.place(box);
}
/** Takes the window off the screen; the pane in it stays where it is until the caller moves it. */
close(): void {
if (!this.isOpen) return;
ui().app.renderer.off('resize', this.keepOnScreen);
this.parent!.removeChild(this);
// A field in the window would keep the keyboard after the window is gone, as in `Popup.close`.
if (ui().focus.isWithin(this)) ui().focus.blur();
}
toFront(): void {
const overlay = this.parent;
if (overlay && overlay.getChildIndex(this) < overlay.children.length - 1)
overlay.setChildIndex(this, overlay.children.length - 1);
}
private grab(kind: 'move' | 'size', e: FederatedPointerEvent): void {
e.stopPropagation();
this.gesture = { kind, x: e.global.x, y: e.global.y, box: this.box };
}
private drag(e: FederatedPointerEvent): void {
const g = this.gesture;
if (!g) return;
const screen = ui().app.screen;
const dx = e.global.x - g.x;
const dy = e.global.y - g.y;
if (g.kind === 'size') this.place(sized(g.box, dx, dy, screen.width, screen.height));
else this.place({ ...g.box, x: g.box.x + dx, y: g.box.y + dy });
}
private readonly keepOnScreen = () => this.place(this.box);
private place(box: WindowBox): void {
const screen = ui().app.screen;
const fit = onScreen(box, screen.width, screen.height);
this.position.set(fit.x, fit.y);
this.resize(fit.w, fit.h);
}
override layout(): void {
const line = 1;
const barH = this.bar.measure().h;
this.bar.position.set(line, line);
this.bar.resize(Math.max(0, this.w - line * 2), barH);
if (this.content) {
this.content.position.set(line, line + barH);
// Laid out even at the same size: a pane that comes from the tab row may not have been.
const w = Math.max(0, this.w - line * 2);
const h = Math.max(0, this.h - line * 2 - barH);
if (this.content.w === w && this.content.h === h) this.content.layout();
this.content.resize(w, h);
}
this.grip.position.set(this.w - GRIP - line, this.h - GRIP - line);
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear();
this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow
drawControlBox(this.bg, this.w, this.h, { fill: c.panel, border: c.border });
// Three short diagonals in the corner, the usual sign of a handle that sizes a window.
this.grip.clear();
for (const at of [4, 8, 12]) this.grip.moveTo(GRIP - at, GRIP).lineTo(GRIP, GRIP - at);
this.grip.stroke({ width: 1, color: c.textMuted });
}
}
/** Whether a press landed on one of the title bar's buttons rather than on the bar itself. */
function onButton(target: Container | null, bar: Container): boolean {
for (let node = target; node && node !== bar; node = node.parent) if (node instanceof Button) return true;
return false;
}
+10 -1
View File
@@ -31,6 +31,8 @@ export interface DocumentSpec {
kind: string; kind: string;
icon: string; icon: string;
onClose: () => void; onClose: () => void;
/** A right-click on the tab (#371). */
onMenu?: (globalX: number, globalY: number) => void;
} }
/** /**
@@ -103,7 +105,14 @@ export class TabStrip<T extends string> extends HStack {
private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void { private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void {
const button = document const button = document
? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose) ? new DocumentTab(
label,
document.kind,
document.icon,
() => this.onPick(tab),
document.onClose,
document.onMenu,
)
: new TabButton(label, { icon, onClick: () => this.onPick(tab) }); : new TabButton(label, { icon, onClick: () => this.onPick(tab) });
this.order.splice(at, 0, tab); this.order.splice(at, 0, tab);
this.buttons.set(tab, button); this.buttons.set(tab, button);
+5
View File
@@ -136,6 +136,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
documents: () => editor.openDocuments(), documents: () => editor.openDocuments(),
openDocument: (doc: DocumentId) => editor.openDocument(doc), openDocument: (doc: DocumentId) => editor.openDocument(doc),
closeDocument: (doc: DocumentId) => editor.closeDocument(doc), closeDocument: (doc: DocumentId) => editor.closeDocument(doc),
/** The tabs in windows of their own (#371): kind, document, box, and where to press. */
floatWindows: () => editor.floatWindows(),
floatDocument: (doc: DocumentId) => editor.floatDocument(doc),
/** The kind Delete, Copy and Paste go to: the window pressed last, else the tab showing. */
keyTarget: () => editor.keyTarget(),
plugins: { plugins: {
/** Every file in `data/plugins/` and what became of it. */ /** Every file in `data/plugins/` and what became of it. */
records: () => [...(editor.plugins?.records ?? [])], records: () => [...(editor.plugins?.records ?? [])],
+11
View File
@@ -28,6 +28,17 @@ export function closeDocument<T>(row: DocumentRow<T>, doc: T): DocumentRow<T> {
return { docs, active: docs[Math.max(0, at - 1)] ?? null }; return { docs, active: docs[Math.max(0, at - 1)] ?? null };
} }
/**
* What shows when the document that stood at `at` in `docs` has left it (#371): the nearest one to
* its left that `fits`, else the nearest to its right – as `closeDocument` picks, but skipping the
* documents that cannot show in the tabs now. Null means the scene.
*/
export function neighbourOf<T>(docs: readonly T[], at: number, fits: (doc: T) => boolean): T | null {
for (let i = Math.min(at, docs.length) - 1; i >= 0; i--) if (fits(docs[i]!)) return docs[i]!;
for (let i = Math.max(0, at); i < docs.length; i++) if (fits(docs[i]!)) return docs[i]!;
return null;
}
/** /**
* Puts `to` where `from` is – a tab that goes on to another document, like a behavior followed * 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. * into a call. Where `to` was open already, that tab goes: a document has one tab.
+44
View File
@@ -0,0 +1,44 @@
/**
* Where a tab's window sits (#371). Nothing of it is kept: a window opens in the middle of the
* screen every time, and is held on the screen while it is moved, sized, or the page shrinks.
*/
export interface WindowBox {
x: number;
y: number;
w: number;
h: number;
}
/** Below this a tab's controls no longer fit in a row, and the title bar loses its buttons. */
export const MIN_WINDOW_WIDTH = 320;
export const MIN_WINDOW_HEIGHT = 200;
/** How far each further window opens down and to the right, so it does not hide the one before. */
export const WINDOW_STEP = 24;
/** Half the screen's width and 60 % of its height, in the middle; the `count`-th one a step further. */
export function openingBox(screenW: number, screenH: number, count: number): WindowBox {
const w = Math.min(screenW, Math.max(MIN_WINDOW_WIDTH, Math.round(screenW * 0.5)));
const h = Math.min(screenH, Math.max(MIN_WINDOW_HEIGHT, Math.round(screenH * 0.6)));
const step = count * WINDOW_STEP;
return onScreen(
{ x: Math.round((screenW - w) / 2) + step, y: Math.round((screenH - h) / 2) + step, w, h },
screenW,
screenH,
);
}
/** `box` whole on the screen: no larger than it, and moved in where it sticks out. */
export function onScreen(box: WindowBox, screenW: number, screenH: number): WindowBox {
const w = Math.max(Math.min(box.w, screenW), Math.min(MIN_WINDOW_WIDTH, screenW));
const h = Math.max(Math.min(box.h, screenH), Math.min(MIN_WINDOW_HEIGHT, screenH));
const x = Math.max(0, Math.min(box.x, screenW - w));
const y = Math.max(0, Math.min(box.y, screenH - h));
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
}
/** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */
export function sized(box: WindowBox, dx: number, dy: number, screenW: number, screenH: number): WindowBox {
const w = Math.max(MIN_WINDOW_WIDTH, Math.min(box.w + dx, screenW - box.x));
const h = Math.max(MIN_WINDOW_HEIGHT, Math.min(box.h + dy, screenH - box.y));
return { ...box, w: Math.round(w), h: Math.round(h) };
}
+38 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { closeDocument, openDocument, replaceDocument } from '../../src/editor/documents'; import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents';
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, onScreen, openingBox, sized } from '../../src/editor/floating';
import { tabRowLayout } from '../../src/editor/tabRow'; import { tabRowLayout } from '../../src/editor/tabRow';
const row = (docs: string[], active: string | null) => ({ docs, active }); const row = (docs: string[], active: string | null) => ({ docs, active });
@@ -52,6 +53,42 @@ describe('open documents', () => {
}); });
}); });
describe('what shows when a document goes to a window (#371)', () => {
const all = () => true;
it('picks the left neighbour, else the right one, else none', () => {
expect(neighbourOf(['a', 'c'], 1, all)).toBe('a');
expect(neighbourOf(['b', 'c'], 0, all)).toBe('b');
expect(neighbourOf([], 0, all)).toBeNull();
});
it('passes over documents whose kind is in a window', () => {
const tabs = (doc: string) => !doc.startsWith('fx');
expect(neighbourOf(['a', 'fx1', 'fx2', 'b'], 3, tabs)).toBe('a');
expect(neighbourOf(['fx1', 'fx2', 'b'], 1, tabs)).toBe('b');
expect(neighbourOf(['fx1', 'fx2'], 1, tabs)).toBeNull();
});
});
describe('a tab in a window (#371)', () => {
it('opens in the middle of the screen, every further window a step lower right', () => {
expect(openingBox(1280, 800, 0)).toEqual({ x: 320, y: 160, w: 640, h: 480 });
expect(openingBox(1280, 800, 2)).toEqual({ x: 368, y: 208, w: 640, h: 480 });
});
it('is held whole on the screen when it is moved past an edge', () => {
expect(onScreen({ x: 900, y: -40, w: 640, h: 480 }, 1280, 800)).toEqual({ x: 640, y: 0, w: 640, h: 480 });
expect(onScreen({ x: 100, y: 100, w: 640, h: 480 }, 500, 400)).toEqual({ x: 0, y: 0, w: 500, h: 400 });
});
it('is sized from its lower right corner, down to a floor and out to the screen’s edge', () => {
const box = { x: 600, y: 300, w: 400, h: 300 };
expect(sized(box, 100, 50, 1280, 800)).toEqual({ ...box, w: 500, h: 350 });
expect(sized(box, -500, -500, 1280, 800)).toEqual({ ...box, w: MIN_WINDOW_WIDTH, h: MIN_WINDOW_HEIGHT });
expect(sized(box, 900, 900, 1280, 800)).toEqual({ ...box, w: 680, h: 500 });
});
});
describe('tab row without an active tab', () => { describe('tab row without an active tab', () => {
it('fills the row in order when a kind with no document is showing', () => { it('fills the row in order when a kind with no document is showing', () => {
const tabs = [ const tabs = [
+105
View File
@@ -157,6 +157,111 @@ export async function documents(t) {
await mutate( await mutate(
`__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`, `__gm.state.project.assets.push(window.__deletedSheet); delete window.__deletedSheet; __gm.state.preview();`,
); );
// 44i. A right-click on a tab floats it: the document leaves the row for a window of its own.
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${a.id}`)})`);
const tabOfA = await place(`asset:${a.id}`);
await click(tabOfA.x + 20, tabOfA.y + tabOfA.h / 2, 'right');
const floatEntry = (await json(`__gm.locateAll('Float')`)).find(
(p) => p.y > tabOfA.y + tabOfA.h && Math.abs(p.x - tabOfA.x) < 200,
);
if (floatEntry) await click(floatEntry.x, floatEntry.y);
const windows = () => json('__gm.floatWindows()');
const floated = await windows();
const inWindow = await json('__gm.texture.rect()');
const box = floated[0]?.box;
check(
'Float in a tab’s menu moves the document into a window, the texture in it',
!(await docs()).docs.includes(`asset:${a.id}`) &&
floated.length === 1 &&
floated[0].kind === 'texture' &&
floated[0].doc === `asset:${a.id}` &&
floated[0].title === a.name &&
(await json('__gm.tab()')) === 'scene' &&
(await current()) === a.id &&
inWindow.x >= box.x &&
inWindow.x + inWindow.w <= box.x + box.w &&
inWindow.y > box.y &&
inWindow.y + inWindow.h <= box.y + box.h,
JSON.stringify({ floatEntry, floated, inWindow, docs: await docs() }),
);
// 44j. The title bar moves the window, its corner sizes it, and the texture grows along.
await t.drag(floated[0].bar.x, floated[0].bar.y, floated[0].bar.x - 100, floated[0].bar.y - 60);
const moved = (await windows())[0];
await t.drag(moved.grip.x, moved.grip.y, moved.grip.x + 80, moved.grip.y + 40);
const grown = (await windows())[0];
const grownRect = await json('__gm.texture.rect()');
check(
'dragging the title bar moves the window, dragging its corner sizes it',
moved.box.x === box.x - 100 &&
moved.box.y === box.y - 60 &&
grown.box.w === box.w + 80 &&
grown.box.h === box.h + 40 &&
grown.box.x === moved.box.x &&
grownRect.w === inWindow.w + 80,
JSON.stringify({ box, moved: moved.box, grown: grown.box, inWindow, grownRect }),
);
await shot('44-float');
// 44k. Another texture opened goes into the window: the kind has one editor, so one window.
const rowB = await json(`__gm.assetRow(${JSON.stringify(b.id)})`);
await doubleClick(rowB.x, rowB.y);
const other = await windows();
check(
'opening another texture shows it in the window, not in a tab',
other.length === 1 &&
other[0].doc === `asset:${b.id}` &&
other[0].title === b.name &&
!(await docs()).docs.includes(`asset:${b.id}`) &&
(await current()) === b.id,
JSON.stringify({ other, docs: await docs() }),
);
// 44l. Delete, Copy and Paste go where the last press went.
await click(other[0].bar.x, other[0].bar.y);
const toWindow = await json('__gm.keyTarget()');
const scene = (await json('__gm.tabRow()')).shown.find((p) => p.tab === 'scene');
await click((scene.x + scene.right) / 2, scene.y + scene.h / 2);
const toTabs = await json('__gm.keyTarget()');
check(
'keys go to the window after a press in it, and to the tab after a press in the editor',
toWindow === 'texture' && toTabs === 'scene',
JSON.stringify({ toWindow, toTabs }),
);
// 44m. Dock puts the document back into the row, showing, with its pane below the row.
await click(other[0].dock.x, other[0].dock.y);
const docked = await docs();
const dockedRect = await json('__gm.texture.rect()');
check(
'Dock brings the document back as a tab, showing',
(await windows()).length === 0 &&
docked.active === `asset:${b.id}` &&
(await json('__gm.tab()')) === 'texture' &&
(await current()) === b.id &&
dockedRect.w > 0 &&
dockedRect.h > 0,
JSON.stringify({ docked, dockedRect }),
);
// 44n. The window's × closes the document, and the kind shows in the tabs again.
await evaluate(`__gm.floatDocument(${JSON.stringify(`asset:${b.id}`)})`);
const again = await windows();
await click(again[0].close.x, again[0].close.y);
const closed = { windows: await windows(), docs: await docs() };
await showTab('texture');
check(
'the × closes the window and its document; the kind opens in a tab again',
closed.windows.length === 0 &&
!closed.docs.docs.includes(`asset:${b.id}`) &&
(await json('__gm.tab()')) === 'texture' &&
(await json('__gm.texture.rect()')).w > 0,
JSON.stringify({ again, closed, tab: await json('__gm.tab()') }),
);
for (const doc of (await docs()).docs) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
await showTab('scene');
await mutate(` await mutate(`
for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was; for (const { id, was } of ${JSON.stringify([a, b])}) __gm.state.project.assets.find((x) => x.id === id).name = was;
__gm.state.preview(); __gm.state.preview();