Show a Compressor's panel in its node, its tab on a double-click (#380)
game-maker / gate (push) Successful in 4m2s
game-maker / suite (push) Successful in 16m14s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 11:41:28 +02:00
1 parent 9917253751
commit 1f2b64fb4c
18 files changed
+490 -285

No files matched your search

+78 -12
View File
@@ -33,6 +33,7 @@ import {
} from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { collapsedNodeWidth } from './folding';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
/** Node geometry in graph units; exported because edge anchors must land exactly on these ports. */
@@ -48,6 +49,8 @@ const CHEVRON_SLOT = 14;
const TITLE_X = PAD + CHEVRON_SLOT;
/** Right of a collapsed title: the side's link dot sits centred on the edge. */
const COLLAPSED_TITLE_END = PAD + PORT_RADIUS;
/** From a node's edge to the name of a port. */
const PORT_TEXT_X = PAD + PORT_RADIUS + 4;
function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
@@ -163,6 +166,8 @@ export class NodeView extends Widget {
private textsOf = '';
private drawnOf = '';
private pictureOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
@@ -175,6 +180,8 @@ export class NodeView extends Widget {
constructor(
readonly node: GraphNode,
def: NodeDef,
/** What the node shows instead of its rows (#380), if anything. */
readonly face: NodeFace | null = null,
) {
super();
this.def = def;
@@ -185,6 +192,7 @@ export class NodeView extends Widget {
this.picture.context.batchMode = 'batch';
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.picture, this.ports);
if (face) this.addChild(face.widget);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -251,16 +259,37 @@ export class NodeView extends Widget {
)
this.inlineTexts.set(port.key, NULL_TEXT);
}
// A face shows the settings and the values itself: its sides name the ports only.
if (this.shownFace()) {
this.paramLines = [];
this.inlineTexts = new Map();
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
outputs: graph.edges.some((e) => e.from.node === node.id),
};
const picture = this.shownPicture();
const face = this.shownFace();
if (face) this.sides = this.faceSides();
const height = this.collapsed
? TITLE_HEIGHT
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
: face
? this.headerHeight() + this.faceSize().h + PAD
: this.rowsEnd() + PAD + (picture ? picture.height + PAD / 2 : 0);
// Sized before the texts: output labels are placed against the right edge.
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
this.resize(
this.collapsed
? this.collapsedWidth()
: face
? this.sides.left + this.faceSize().w + this.sides.right
: NODE_WIDTH,
height,
);
if (this.face) {
this.face.widget.visible = face !== null;
if (face) this.placeFace(face);
else this.face.hide();
}
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
@@ -400,6 +429,36 @@ export class NodeView extends Widget {
return this.headerHeight() + this.rowCount() * ROW_HEIGHT;
}
/** The node's face, while the node is open. */
private shownFace(): NodeFace | null {
return (!this.collapsed && this.face) || null;
}
private faceSize(): { w: number; h: number } {
return this.face?.widget.measure() ?? { w: 0, h: 0 };
}
/** Each side of a face as wide as the longest name on it. */
private faceSides(): { left: number; right: number } {
const side = (ports: PortDef[]) =>
Math.ceil(
PORT_TEXT_X +
Math.max(
0,
...ports.map((p) => CanvasTextMetrics.measureText(p.label, textStyle(rowStyle(p))).width),
) +
PAD,
);
return { left: side(this.def.inputs), right: side(this.def.outputs) };
}
private placeFace(face: NodeFace): void {
const size = this.faceSize();
face.widget.position.set(this.sides.left, this.headerHeight());
face.widget.resize(size.w, size.h);
face.sync();
}
/** The node's picture, while the node is open. */
private shownPicture(): NodePicture | null {
return (!this.collapsed && this.def.picture) || null;
@@ -444,8 +503,10 @@ export class NodeView extends Widget {
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
}
/** A face has its ports in the middle of its sides. */
private rowY(index: number): number {
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
const top = this.shownFace() ? (this.faceSize().h - this.rowCount() * ROW_HEIGHT) / 2 : 0;
return this.headerHeight() + top + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/**
@@ -506,15 +567,13 @@ export class NodeView extends Widget {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
if (this.problem) {
add(this.problem, 'warning', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
const inputX = PAD + PORT_RADIUS + 4;
if (this.problem) add(this.problem, 'warning', PAD + 2, y + 2);
const inputX = PORT_TEXT_X;
const face = this.shownFace() !== null;
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
const rowTop = this.rowY(i) - ROW_HEIGHT / 2 + 2;
const outputText = output
? add(
output.label,
@@ -522,15 +581,17 @@ export class NodeView extends Widget {
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
face ? this.sides.right : NODE_WIDTH / 2 - 10,
)
: null;
const room = inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
const room = face
? this.sides.left
: inputTextRoom(inputX, NODE_WIDTH / 2 + 20, outputText ? outputText.x : null);
const inputText = input
? add(this.inputText(input), rowStyle(input), inputX, rowTop, 'left', room)
: null;
this.rowTexts.push({ input: inputText, output: outputText });
if (output && outputText && output.type !== 'exec' && !output.ghost) {
if (!face && output && outputText && output.type !== 'exec' && !output.ghost) {
const inputEnd = inputText ? inputText.x + inputText.width : PAD;
this.valueSlots.set(output.key, { ...valueSpan(inputEnd, outputText.x), top: rowTop + 1 });
}
@@ -538,6 +599,11 @@ export class NodeView extends Widget {
this.placeValues();
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.face?.hide();
super.destroy(options);
}
override redraw(): void {
this.drawPicture();
const drawn = this.drawSignature();