Edit a Filter in its node and resize EQ and Filter nodes (#385)

A Filter sounds like one band of the Parametric EQ, but it keeps what
the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a
node of its own and gets the EQ's editor with itself as the one band.
The editors also stop running out of their room: what does not fit is
left out instead of being cut.

- The curve of the EQ is now BandCurve with a host the EQ and the
  Filter both fill. CurvePanel holds what their panels share.
- FilterPanel shows the Filter as one band. A drag of its handle sets
  frequency and gain as one undo step. A number fed by a link is
  greyed out and the drag leaves it alone.
- A double-click on the Filter's title opens the same in a tab of its
  own. Delete there does nothing, the node stays.
- The Filter is tapped like the EQ, so its node and its tab draw the
  spectrum of what passes it. A tap is two gain nodes per voice, the
  analysers are made only while someone listens.
- EqFace and FilterFace are one CurveFace now, given the panel.
- EQ and Filter nodes have a corner to resize them, drawn like the
  floating window's. The size is saved on the node as `size`, each
  drag is one undo step, the least is 240 x 120.
- In the node the help line is gone. The row of numbers comes in only
  once the curve keeps 120 px of height. A group of the row that does
  not fit is not drawn, the one on the right first.
- A frequency or dB label that would run into the one before it is
  left out.
- The all-pass is drawn flat. The curve's maths takes every shape a
  Filter has.

Found on the way:
- The instrument presets set nodes 240 apart. A Filter with its
  editor is about 700 wide and covered the Bitcrusher, so a Filter
  now takes four columns.

Tests:
- Unit: 8 new (filter band, resize command and save, fitting rules),
  stage lists with a Filter now hold its taps
- e2e 34 (atx): ALL PASSED, 130 checks (4 new)
- Gate (atx): CHECK EXIT 0, 284 files, 4367 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 14:30:00 +02:00
1 parent 17f47f6956
commit c087993000
26 files changed
+1514 -656

No files matched your search

+62 -1
View File
@@ -1,4 +1,4 @@
import { CanvasTextMetrics, Graphics, Rectangle, type Text } from 'pixi.js';
import { CanvasTextMetrics, Container, Graphics, Rectangle, type Text } from 'pixi.js';
import {
asNumber,
asString,
@@ -31,6 +31,7 @@ import {
crispText,
} from '../../ui';
import { keyLabel, referenceLabel } from '../inspector/fieldFactory';
import { RESIZE_HANDLE } from './AnnotationView';
import { collapsedNodeWidth } from './folding';
import type { NodeFace } from './nodeFace';
import { inputTextRoom, valueSpan } from './portRow';
@@ -164,6 +165,10 @@ export class NodeView extends Widget {
private drawnOf = '';
/** How wide the sides of a face are, with the pins and their names (#380). */
private sides = { left: 0, right: 0 };
/** The corner a face that may be resized is dragged by (#385); it only shows the cursor. */
private readonly corner = new Container();
/** What the node was last drawn from, so a drag of its corner can draw it again (#385). */
private last: { graph: Graph; project: Project | null; problem: string | null } | null = null;
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
@@ -188,6 +193,10 @@ export class NodeView extends Widget {
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.ports);
if (face) this.addChild(face.widget);
this.corner.eventMode = 'static';
this.corner.cursor = 'nwse-resize';
this.corner.hitArea = new Rectangle(0, 0, RESIZE_HANDLE, RESIZE_HANDLE);
this.addChild(this.corner);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -216,6 +225,7 @@ export class NodeView extends Widget {
): void {
this.def = def;
this.problem = problem;
this.last = { graph, project, problem };
this.alpha = def.dimmed?.(node.params) ? DIMMED_ALPHA : 1;
const hidden = new Set([
...(this.def.hiddenParams?.(node.params) ?? []),
@@ -284,6 +294,8 @@ export class NodeView extends Widget {
if (face) this.placeFace(face);
else this.face.hide();
}
this.corner.visible = this.resizable;
this.corner.position.set(this.w - RESIZE_HANDLE, this.h - RESIZE_HANDLE);
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
@@ -324,6 +336,7 @@ export class NodeView extends Widget {
this.linked,
this.targetPort,
this.headerHeight(),
this.resizable,
this.def.inputs.map(port),
this.def.outputs.map(port),
]);
@@ -428,10 +441,52 @@ export class NodeView extends Widget {
return (!this.collapsed && this.face) || null;
}
/** The size the node was dragged to (#385), never below the least its face takes; else the face's own. */
private faceSize(): { w: number; h: number } {
const min = this.face?.minSize;
const size = this.node.size;
if (min && size) return { w: Math.max(min.w, size.w), h: Math.max(min.h, size.h) };
return this.face?.widget.measure() ?? { w: 0, h: 0 };
}
/** Whether the node has a corner to resize its face by: an open node whose face may be (#385). */
get resizable(): boolean {
return this.shownFace()?.minSize !== undefined;
}
/** Whether a local point is on that corner. */
cornerAt(x: number, y: number): boolean {
return (
this.resizable &&
x >= this.w - RESIZE_HANDLE &&
x <= this.w &&
y >= this.h - RESIZE_HANDLE &&
y <= this.h
);
}
cornerGlobal(): { x: number; y: number } | null {
if (!this.resizable) return null;
const at = this.toGlobal({ x: this.w - RESIZE_HANDLE / 2, y: this.h - RESIZE_HANDLE / 2 });
return { x: at.x, y: at.y };
}
/** How much of the node is not its face: the least the node itself can be dragged to follows from it. */
get faceChrome(): { w: number; h: number } {
const size = this.faceSize();
return { w: this.w - size.w, h: this.h - size.h };
}
/** A drag of the corner changed the node's size in the model: the node is drawn at it. */
refit(): void {
const last = this.last;
const face = this.shownFace();
if (!last || !face) return;
const size = this.faceSize();
if (size.w === face.widget.w && size.h === face.widget.h) return;
this.sync(this.node, last.graph, last.project, this.def, last.problem);
}
/** Each side of a face as wide as the longest name on it. */
private faceSides(): { left: number; right: number } {
const side = (ports: PortDef[]) =>
@@ -591,6 +646,12 @@ export class NodeView extends Widget {
this.box
.roundRect(2.5, 2.5, this.w - 5, this.h - 5, Theme.radius)
.stroke({ width: 2, color: c.hot, alpha: this.stateMark === 'current' ? 1 : 0.35 });
if (this.resizable) {
// The floating window's corner (#371), so it reads as the same handle.
for (const at of [4, 8, 12])
this.box.moveTo(this.w - 2 - at, this.h - 2).lineTo(this.w - 2, this.h - 2 - at);
this.box.stroke({ width: 1, color: c.textMuted });
}
this.ports.clear();
const r = PORT_RADIUS;