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>
224 lines
8.4 KiB
TypeScript
224 lines
8.4 KiB
TypeScript
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;
|
||
}
|