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

+235 -31
View File
@@ -31,7 +31,9 @@ import {
} from '../ui';
import { TabStrip, type TabPlace } from './TabStrip';
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 { GamePane } from './viewport/GamePane';
import { ScenePane } from './viewport/ScenePane';
@@ -299,6 +301,13 @@ export class Editor implements PlayHost {
private readonly panes: Record<CentreTab, Widget>;
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
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>();
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
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.
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}`);
if (next === this.documents) return;
this.documents = next;
@@ -527,6 +541,8 @@ export class Editor implements PlayHost {
this.workspace.add(this.inspector);
root.add(this.workspace, 1);
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.cleanups.push(
@@ -535,9 +551,9 @@ export class Editor implements PlayHost {
stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(),
copy: () =>
(this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) ||
(this.tab === 'pattern' && this.patternEditor.canvas.copy()) ||
(this.tab === 'song' && this.songEditor.canvas.copy()) ||
(this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) ||
(this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) ||
(this.keyTab() === 'song' && this.songEditor.canvas.copy()) ||
copySelection(state),
paste: () => this.paste(),
removePoint: () => this.removePoint(),
@@ -597,6 +613,7 @@ export class Editor implements PlayHost {
state.on('load', () => {
syncMix();
this.editorRun.invalidate();
for (const kind of [...this.floats.keys()]) this.closeWindow(kind);
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
@@ -635,13 +652,25 @@ export class Editor implements PlayHost {
private showsPick(): boolean {
const pick = this.state.objectPick;
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';
}
/**
* 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. */
private removePoint(): boolean {
switch (this.tab) {
switch (this.keyTab()) {
case 'scene':
return this.viewport.removePickedCorner();
case 'container':
@@ -667,7 +696,7 @@ export class Editor implements PlayHost {
/** Ctrl+V lands in whatever the visible tab edits (#240). */
private paste(): boolean {
switch (this.tab) {
switch (this.keyTab()) {
case 'animation':
return this.animationEditor.canvas.pasteKeys();
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. */
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) {
// Still worth a look at the name: a Variables tab's owner may have been renamed.
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. */
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;
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.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);
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc);
if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.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');
}
/**
* 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
* 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;
this.toolbar.visible = !on;
this.workspace.visible = !on;
for (const { window } of this.floats.values()) window.visible = !on;
if (on) this.root.add(this.gameView, 1);
else this.gamePane.add(this.gameView, 1);
this.gameView.setFullscreen(on);
@@ -911,6 +1099,16 @@ export class Editor implements PlayHost {
private showDocument(doc: DocumentId): void {
const kind = this.kindOf(doc);
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));
const target = this.valueTargets.get(doc);
if (kind === 'variables' && target) this.variablesEditor.show(target);
@@ -919,7 +1117,6 @@ export class Editor implements PlayHost {
if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc);
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. */
@@ -957,6 +1154,7 @@ export class Editor implements PlayHost {
kind: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
icon: this.iconOf(doc),
onClose: () => this.closeDocument(doc),
onMenu: (x, y) => this.openTabMenu(doc, x, y),
});
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. */
private syncDocuments(): void {
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();
}
@@ -980,7 +1181,8 @@ export class Editor implements PlayHost {
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => 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;
// The view checks its own flag before it redraws, and the row around it is what switches now.
this.viewport.visible = tab === 'scene';
@@ -1047,6 +1249,8 @@ export class Editor implements PlayHost {
this.editorRun.shutDown();
await this.plugins?.stopAll();
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.
this.passwordDialog.current()?.close();
this.generalDialog.current()?.close();
@@ -1087,20 +1291,20 @@ export class Editor implements PlayHost {
this.viewport.animate(sceneDraws);
this.gameView.animate(gameDraws);
this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(step);
if (this.tab === 'tile') this.tileEditor.tick(step);
if (this.shows('animation')) this.animationEditor.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.
if (this.tab === 'curve') this.curveEditor.tick(dt);
if (this.tab === 'pattern') this.patternEditor.tick();
if (this.tab === 'song') this.songEditor.tick();
if (this.shows('curve')) this.curveEditor.tick(dt);
if (this.shows('pattern')) this.patternEditor.tick();
if (this.shows('song')) this.songEditor.tick();
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
// to do it, and its states move just the same.
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.tab === 'particles') this.particlesEditor.tick();
if (this.tab === 'shader') this.shaderEditor.tick();
if (this.tab === 'pixelart') this.pixelArtEditor.tick();
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.shows('particles')) this.particlesEditor.tick();
if (this.shows('shader')) this.shaderEditor.tick();
if (this.shows('pixelart')) this.pixelArtEditor.tick();
if (this.movesByItself()) requestRender();
const drawn = this.gameView.drawTime - drawnBefore;
if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime()))
@@ -1122,15 +1326,15 @@ export class Editor implements PlayHost {
this.editorRun.isRunning ||
(this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' ||
this.tab === 'pixelart' ||
(this.tab === 'shader' && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying) ||
(this.tab === 'curve' && this.curveEditor.isPlaying) ||
(this.tab === 'pattern' && this.patternEditor.isPlaying) ||
(this.tab === 'song' && this.songEditor.isPlaying) ||
(this.tab === 'sound' && this.soundEditor.isPlaying)
this.shows('particles') ||
this.shows('pixelart') ||
(this.shows('shader') && this.shaderEditor.animates) ||
(this.shows('animation') && this.animationEditor.isPlaying) ||
(this.shows('tile') && this.tileEditor.isPlaying) ||
(this.shows('curve') && this.curveEditor.isPlaying) ||
(this.shows('pattern') && this.patternEditor.isPlaying) ||
(this.shows('song') && this.songEditor.isPlaying) ||
(this.shows('sound') && this.soundEditor.isPlaying)
);
}