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

+17 -4
View File
@@ -371,7 +371,11 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
if (!asset) return;
this.annotationLayer.sync(asset.graph);
for (const node of asset.graph.nodes) this.views.get(node.id)?.position.set(node.x, node.y);
for (const node of asset.graph.nodes) {
const view = this.views.get(node.id);
view?.position.set(node.x, node.y);
view?.refit();
}
this.redraw();
}
@@ -539,14 +543,17 @@ export class GraphCanvas extends Widget {
return this.views.get(nodeId)?.face ?? null;
}
/** A node's box on screen and the keys of its ports, for the check scripts. */
nodeScreen(nodeId: string): { rect: Rect; inputs: string[]; outputs: string[] } | null {
/** A node's box on screen, the keys of its ports and the corner it is resized by (#385), for the check scripts. */
nodeScreen(
nodeId: string,
): { rect: Rect; inputs: string[]; outputs: string[]; corner: { x: number; y: number } | null } | null {
const view = this.views.get(nodeId);
if (!view) return null;
return {
rect: globalRect(view, view.w, view.h),
inputs: view.def.inputs.map((p) => p.key),
outputs: view.def.outputs.map((p) => p.key),
corner: view.cornerGlobal(),
};
}
@@ -697,6 +704,12 @@ export class GraphCanvas extends Widget {
this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global);
return;
}
if (view.cornerAt(local.x, local.y)) {
this.state.select(nodeSelection(this.assetId, [view.node.id]));
this.nodeLayer.addChild(view); // raise
this.drags.grabCorner(view, e.global);
return;
}
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
if (view.chevronAt(local.x, local.y)) {
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
@@ -843,7 +856,7 @@ export class GraphCanvas extends Widget {
return;
}
this.panning = null;
if (this.drags.releaseBox()) {
if (this.drags.releaseCorner() || this.drags.releaseBox()) {
this.redraw();
return;
}
+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;
+68 -2
View File
@@ -1,8 +1,9 @@
import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { ResizeNodeCommand } from '../../core/commands/graphCommands';
import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph';
import { cancelFrameWork, onFrame } from '../../ui';
import { dragOffset, resizeSize } from '../framing';
import { type Box, dragOffset, resizeSize } from '../framing';
import {
boxOf,
type BoxSeed,
@@ -15,6 +16,7 @@ import {
import type { AnnotationView } from './AnnotationView';
import type { GraphCanvasHost } from './graphHost';
import { GRID_STEP } from './graphView';
import type { NodeView } from './NodeView';
import type { Point } from './transitions';
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
@@ -38,6 +40,19 @@ export class GraphDrags {
pointer: Point;
moved: boolean;
} | null = null;
/**
* A node dragged by its corner (#385): the node as it was grabbed, how much of it is not its face,
* and the least it can be. The model holds the face's size, the drag works on the whole node.
*/
private corner: {
nodeId: string;
start: Omit<Box, 'id'>;
chrome: { w: number; h: number };
min: { w: number; h: number };
before: { w: number; h: number } | undefined;
pointer: Point;
moved: boolean;
} | null = null;
/**
* The gesture writes the model on every pointer move, the canvas follows once a frame (#213) – and
@@ -90,9 +105,27 @@ export class GraphDrags {
};
}
/** A press on the corner of a node whose face may be resized (#385). */
grabCorner(view: NodeView, global: Point): void {
const min = view.face?.minSize;
if (!min) return;
const chrome = view.faceChrome;
const node = view.node;
this.corner = {
nodeId: node.id,
start: { x: node.x, y: node.y, w: view.w, h: view.h },
chrome,
min: { w: min.w + chrome.w, h: min.h + chrome.h },
before: node.size && { ...node.size },
pointer: this.host.toGraph(global),
moved: false,
};
}
/** Moves what is dragged in the model, for the preview; false when nothing is. */
move(global: Point): boolean {
if (this.box) this.previewBox(this.host.toGraph(global));
if (this.corner) this.previewCorner(this.host.toGraph(global));
else if (this.box) this.previewBox(this.host.toGraph(global));
else if (this.nodes) this.previewNodes(this.host.toGraph(global));
else return false;
return true;
@@ -135,6 +168,39 @@ export class GraphDrags {
this.follow();
}
/** Sizes the node in the model, for the preview: its far corner on the grid when snapping, as a group's. */
private previewCorner(g: Point): void {
const drag = this.corner!;
const dx = g.x - drag.pointer.x;
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return;
drag.moved = true;
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
if (!node) return;
const size = resizeSize(drag.start, dx, dy, this.host.snapping() ? GRID_STEP : 0, drag.min);
node.size = {
w: Math.round(Math.max(drag.min.w, size.w) - drag.chrome.w),
h: Math.round(Math.max(drag.min.h, size.h) - drag.chrome.h),
};
this.follow();
}
/** Puts the node's size back and records it as one undo step. False when no corner was dragged. */
releaseCorner(): boolean {
const drag = this.corner;
if (!drag) return false;
this.corner = null;
cancelFrameWork(this.frameKey);
const node = this.host.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
const assetId = this.host.assetId();
if (!drag.moved || !node?.size || !assetId) return true;
const size = node.size;
if (drag.before) node.size = drag.before;
else delete node.size;
this.host.state.execute(new ResizeNodeCommand(assetId, node.id, size));
return true;
}
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
releaseBox(): boolean {
const drag = this.box;
+5
View File
@@ -6,6 +6,11 @@ import type { Widget } from '../../ui';
*/
export interface NodeFace {
readonly widget: Widget;
/**
* The least it can be dragged to, for a face whose node has a corner to resize it by (#385); the
* widget's `measure()` is its size until then.
*/
readonly minSize?: { w: number; h: number };
/** After every change to the model: the widget shows the node as it now is. */
sync(): void;
/** A frame on while the node is in the picture. Whether it still moves. */