Files
feuerwerk/src/editor/FloatWindow.ts
T
adminandClaude Opus 5.5 1f2b64fb4c
game-maker / gate (push) Successful in 4m2s
game-maker / suite (push) Successful in 16m14s
Show a Compressor's panel in its node, its tab on a double-click (#380)
The panel of #379 opened in a window of its own and replaced the tab.
The user wanted the device in the graph instead, and the tab of #378
back on a double-click. The switch Hardware look goes again.

- A node can show a face instead of its rows (`NodeFace`). Its inputs
  sit on the left of it and its outputs on the right, in the middle of
  the height. Each side is as wide as its longest port name.
- A Compressor node shows the panel at full size, 820x280 graph units.
  Its settings and numbers are not listed as rows, the panel has them.
- The knobs and the lever work in the node. A drag, a notch of the
  wheel and a double-click are one undo step each. A press on them
  neither picks nor moves the node, and the wheel does not zoom the
  graph. The number below a knob is only shown, typing stays in the
  tab and the inspector.
- The meter and the LEDs run while the node is in the picture of a sound
  FX or instrument graph that is seen. Out of the picture, folded or
  hidden, the node lets go of its taps.
- The window of one size, `fixedSize`, `refit`, `keptSize` and the rule
  that kept a Compressor out of the tab row are gone again.
- A knob's drag as one undo step lives in `nodeParamDrag.ts` now, for
  the tab and the node. The numbers of a node and what auto-gain says
  moved to `compressorView.ts`.

Found and fixed on the way:
- The tab and the node listen to the same taps. The tab let go of them
  on every frame it was not seen, so the node's meter stood still while
  the tab was open. Now the tab lets go once, and in a frame everything
  unseen lets go before anything seen listens.

Tests:
- Unit: compressor-view (2 new), documents (the test of `keptSize` is
  gone with it)
- e2e 34 (stein2, melektaus-atx was off): ALL PASSED, 122 checks. The
  four checks of #379 are now four of the node: ports beside the panel,
  knobs and lever in the node, the needle when played (21.7 dB with the
  tab open), and the double-click opening the tab.
- Gate (stein2): CHECK EXIT 0, 283 files, 4356 tests
- Counter-checks: an old auto-gain number read as off turns exactly its
  unit test red. A tab that lets go on every frame leaves the node's
  needle at 0 dB and no LED lit (probe on stein2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:41:28 +02:00

195 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui';
import { leastBox, 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();
// A pane that needs more than the window has grows the window.
if (this.isOpen) this.place(this.box);
}
/** 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, this.least()));
else this.place({ ...g.box, x: g.box.x + dx, y: g.box.y + dy });
}
private readonly keepOnScreen = () => this.place(this.box);
/** The window's least size: the usual one, or more where the pane in it says it needs more. */
least(): { w: number; h: number } {
const content = this.content;
if (!content) return leastBox(0, 0);
return leastBox(content.minWidth + 2, content.minHeight + this.bar.measure().h + 2);
}
private place(box: WindowBox): void {
const screen = ui().app.screen;
const fit = onScreen(box, screen.width, screen.height, this.least());
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;
drawFloatingBox(this.bg, this.w, this.h, c.panel);
// 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;
}