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

+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;
}