Files
feuerwerk/src/editor/FloatWindow.ts
T
adminandClaude Opus 5.5 9917253751 Open a Compressor as a device in a window of one size (#379)
The tab from #378 shows a Compressor as an editor. The user wanted it
to look like a device as well, after a brushed aluminium plug-in, with
a meter of the gain reduction instead of the pictures. The tab stays
and is switched to per node.

- Hardware look is a switch under Settings, on by default for new and
  existing Compressors. Only the editor reads it.
- The panel is 820x280 px and opens straight in a window of that size.
  The window has no sizing corner and no Dock. A pane says so through
  `fixedSize`, and `keptSize` keeps the window's middle when it
  changes to that size.
- A Compressor in this look never sits in the tab row. Opening one
  floats it, and switching the look on moves its tab into the window.
- The panel has chrome knobs (threshold and makeup large), a meter
  with a needle that moves like a VU's, an LCD with the name and the
  reading, a lever with a lamp for auto-gain, and LEDs for the level
  going out. Everything is drawn in code.
- The Knob takes a size and a chrome look, and leaves out its caption
  when it has no name, so the panel engraves its own.
- `autoGainOn` is now `switchOn`, because the new switch reads the
  same way.
- Where parts sit, the meter's scale, the needle's speed, the LEDs and
  the plate's grain are in `hardwareView.ts`, apart from the drawing.

Decided myself:
- The meter's scale runs by the square root, so the first dB is about
  as wide as the last nine. It is red from 10 dB on.
- The LEDs light one every 3 dB from -39 to 0 dB and fall by 20 dB a
  second.
- Switching the look changes the graph's fingerprint, like any other
  setting of a node. The sound FX is built again once.

Findings, fixed on the way:
- Pixi's `arc` goes on from the last point of the path. The meter's
  scale had a line from the corner of the face until each arc got a
  `moveTo` to its own start.
- A text put into a Graphics makes Pixi warn that only Containers take
  children. The LCD is a Container now.
- The first browser check read the needle at one moment and missed the
  short sound. The panel keeps the most its needle and LEDs showed.

Tests:
- Unit: hardware-view (10 new), documents (1 new)
- e2e 34 (melektaus-atx): ALL PASSED, 122 checks (4 new). Played,
  the needle reached 20.9 dB and 5 LEDs lit.
- Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4355 tests (11 new)
- Counter-checks: a window that keeps its corner instead of its
  middle, and a linear meter scale, each turn exactly their test red.

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

224 lines
8.4 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 { keptSize, 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 pane that is one size only (#379); null while it takes any. */
export interface SizedPane {
readonly fixedSize: { w: number; h: number } | null;
}
/**
* 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();
const first = this.dockButton.visible ? this.dockButton : this.closeButton;
return { x: at.x + first.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
}
/** The size of a window around a pane of one size, or null where it can be sized. */
get fixedSize(): { w: number; h: number } | null {
const size = (this.content as Partial<SizedPane> | null)?.fixedSize;
return size ? { w: size.w + 2, h: size.h + this.bar.measure().h + 2 } : null;
}
/**
* After the pane has changed what it is: a pane of one size has the window at that size, with no
* corner to size it and no Dock (#379) – it cannot go into the tab row.
*/
refit(): void {
const fixed = this.fixedSize !== null;
this.grip.visible = !fixed;
if (this.dockButton.visible === fixed) {
this.dockButton.visible = !fixed;
this.bar.relayout();
}
if (this.isOpen) this.place(this.box);
}
/** 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.
this.refit();
}
/** 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 fixed = this.fixedSize;
const fit = fixed
? onScreen(keptSize(box, fixed), screen.width, screen.height, fixed)
: 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;
}