From 9ab1bbc507f29612cf3138a0bb23daaf0a841390 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 9 Oct 2026 13:45:02 +0200 Subject: [PATCH] Edit a Parametric EQ in a tab of its own (#370) The EQ's editor floated over the editor as a window since #367. The user wants it as a tab for now. Whether any tab can float and dock again is weighed apart from this. - A double-click on the node asks for the tab through `EditorState.openEq`, as `openValues` does for a Variables tab. - The tab is a document `eq::`, one per EQ node. A second double-click brings the open one up. - Its name is the asset's, with `EQ 2`, `EQ 3` when the asset has more than one. The icon is the sound FX's. - The tab closes with its node, its asset, or a new project, through the same `syncDocuments` as every document. - `EqWindow` becomes `EqEditor`, a `VStack` with a `TabHeader`. The curve takes the room of the tab, 320 x 160 at least. - The window's title bar, dragging and Close button are gone. - Delete in the EQ's tab removes the picked band. It never falls through to the selection, which is still the EQ's node. - The hint below the curve is shorter, it was cut off at 1280 px. Tests: - e2e 34 (melektaus-atx): ALL PASSED, 96 checks, 2 rewritten. The tab opens on a double-click and goes with its node. - Gate (melektaus-atx): CHECK EXIT 0, 274 files, 4231 tests. Co-Authored-By: Claude Opus 5.5 --- src/editor/Editor.ts | 47 +++++++++- src/editor/debug/graphHooks.ts | 40 +++----- src/editor/graph/GraphCanvas.ts | 3 +- src/editor/soundfx/EqEditor.ts | 156 ++++++++++++++------------------ src/editor/state.ts | 11 +++ tools/e2e/34-sound.mjs | 22 +++-- 6 files changed, 150 insertions(+), 129 deletions(-) diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 109738db..eceb358c 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -95,6 +95,7 @@ import { PluginsPane } from './plugins/PluginsPane'; import { ManagementPane } from './users/ManagementPane'; import { UsersPane } from './users/UsersPane'; import { RoutingEditor } from './routing/RoutingEditor'; +import { EqEditor, eqNodeOf, eqTabLabel } from './soundfx/EqEditor'; export type CentreTab = | 'scene' @@ -117,6 +118,7 @@ export type CentreTab = | 'container' | 'variables' | 'routing' + | 'eq' | 'management'; /** A tab a plugin added; the prefix keeps it apart from every built-in one. */ @@ -126,9 +128,9 @@ export type TabId = CentreTab | PluginTabId; /** * An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its * `valuesKey` – as many of those as were opened. The user management (#179) and the master routing - * (#351) are no documents, but they open and close like one. + * (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370). */ -export type DocumentId = `asset:${string}` | `values:${string}` | 'routing' | 'management'; +export type DocumentId = `asset:${string}` | `values:${string}` | `eq:${string}` | 'routing' | 'management'; /** The account behind this session; the menu for it sits at the far right of the toolbar. */ export interface EditorAccount { /** Who is signed in – the menu says so, since the toolbar has no room for a second label. */ @@ -146,7 +148,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId; type FixedTab = (typeof FIXED_TABS)[number]; /** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */ -type AssetKind = Exclude; +type AssetKind = Exclude; const FIXED_TABS = ['scene', 'game'] as const; @@ -171,6 +173,7 @@ const PANE_ORDER = [ 'container', 'variables', 'routing', + 'eq', 'management', ] as const; @@ -196,6 +199,7 @@ const TAB_LABELS: Record = { container: 'Container', variables: 'Variables', routing: 'Master routing', + eq: 'Parametric EQ', management: 'User management', }; @@ -206,7 +210,13 @@ const FIXED_TAB_ICONS: Partial> = { }; function isDocument(tab: StripTab): tab is DocumentId { - return tab === 'management' || tab === 'routing' || tab.startsWith('asset:') || tab.startsWith('values:'); + return ( + tab === 'management' || + tab === 'routing' || + tab.startsWith('asset:') || + tab.startsWith('values:') || + tab.startsWith('eq:') + ); } const MIN_LEFT_WIDTH = 160; @@ -250,8 +260,11 @@ export class Editor implements PlayHost { readonly containerEditor: ContainerEditor; readonly variablesEditor: VariablesEditor; readonly routingEditor: RoutingEditor; + readonly eqEditor: EqEditor; /** What each open Variables tab shows, by its document. */ private readonly valueTargets = new Map(); + /** The node behind each EQ tab (#370). */ + private readonly eqTargets = new Map(); /** The switch per file in `data/plugins/`, in the user management tab (#179). */ readonly pluginsPane = new PluginsPane(); /** The accounts, above the plugins; null unless an administrator is signed in. */ @@ -341,6 +354,7 @@ export class Editor implements PlayHost { this.containerEditor = new ContainerEditor(state); this.variablesEditor = new VariablesEditor(state); this.routingEditor = new RoutingEditor(state); + this.eqEditor = new EqEditor(state); this.hierarchy = new HierarchyPanel(state); this.inspector = new Inspector(state); this.tilemapPanel = new TilemapPanel(state); @@ -463,6 +477,7 @@ export class Editor implements PlayHost { ...this.assetEditors, variables: this.variablesEditor, routing: this.routingEditor, + eq: this.eqEditor, management: this.managementPane, }; // Following a call goes on in the same tab: the tab changes its document, not its place. @@ -562,6 +577,14 @@ export class Editor implements PlayHost { if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target); this.openDocument(doc); }); + // A tab per Parametric EQ node (#370), opened by a double-click on it. + state.on('eq', () => { + const target = state.openedEq; + if (!target) return; + const doc: DocumentId = `eq:${target.assetId}:${target.nodeId}`; + if (!this.eqTargets.has(doc)) this.eqTargets.set(doc, target); + this.openDocument(doc); + }); // The tabs play through the buses and the master as the game would (#325). const syncMix = () => void soundLibrary.setMix(mixOf(state.project)); syncMix(); @@ -577,6 +600,8 @@ export class Editor implements PlayHost { for (const doc of this.documents.docs) this.tabs.removeTab(doc); this.documents = { docs: [], active: null }; this.valueTargets.clear(); + this.eqTargets.clear(); + this.eqEditor.close(); this.showTab('scene'); }); state.on('mode', () => this.editorRun.modeChanged()); @@ -631,6 +656,10 @@ export class Editor implements PlayHost { return this.patternEditor.canvas.deleteSelection(); case 'song': return this.songEditor.canvas.deleteSelection(); + case 'eq': + // The selection is the EQ's node, still selected from the double-click: it is never what goes. + this.eqEditor.removePicked(); + return true; default: return false; } @@ -802,6 +831,7 @@ export class Editor implements PlayHost { if (kind && kind in this.assetEditors) this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.valueTargets.delete(doc); + if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close(); if (doc === 'routing') this.routingEditor.close(); if (!wasShowing) return; if (this.documents.active) this.showDocument(this.documents.active); @@ -869,6 +899,10 @@ export class Editor implements PlayHost { return target && valuesTitle(this.state, target) !== null ? 'variables' : null; } if (doc === 'management' || doc === 'routing') return doc; + if (doc.startsWith('eq:')) { + const target = this.eqTargets.get(doc); + return target && eqNodeOf(this.state.project, target.assetId, target.nodeId) ? 'eq' : null; + } const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; const kind = type ? TAB_OF_ASSET[type] : undefined; return kind && kind !== 'scene' ? kind : null; @@ -883,6 +917,8 @@ export class Editor implements PlayHost { // Read on every showing: an account may have been changed from another tab in the meantime. if (kind === 'management') void this.usersPane?.reload(); if (kind === 'routing') this.routingEditor.open(); + const eq = this.eqTargets.get(doc); + if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId); this.display(kind, doc); } @@ -890,6 +926,8 @@ export class Editor implements PlayHost { private labelOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; if (doc === 'routing') return TAB_LABELS.routing; + const eq = this.eqTargets.get(doc); + if (eq) return eqTabLabel(this.state.project, eq.assetId, eq.nodeId); const target = this.valueTargets.get(doc); if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; const asset = findAsset(this.state.project, doc.slice('asset:'.length)); @@ -900,6 +938,7 @@ export class Editor implements PlayHost { private iconOf(doc: DocumentId): string { if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG; if (doc === 'routing') return ASSET_ICON_SVG.bus; + if (doc.startsWith('eq:')) return ASSET_ICON_SVG.soundfx; const target = this.valueTargets.get(doc); if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables; const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 89313503..bd034def 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -1,6 +1,5 @@ import { NameTransitionCommand } from '../../core/commands/graphCommands'; import { currentNodePalette } from '../graph/NodePalette'; -import { currentEqEditor } from '../soundfx/EqEditor'; import { rectOf } from '../textProbe'; import { centreOf, snapButton, type HookContext } from './hookHelpers'; @@ -117,33 +116,24 @@ export function graphHooks({ state, editor }: HookContext) { setParam: (key: string, value: number) => editor.soundFxEditor.setPreviewValue(key, value), nodeAt: (nodeId: string) => editor.soundFxEditor.canvas.nodeGlobal(nodeId), }, - /** The Parametric EQ's window (#367): for which node it is open, its bands and where to aim. */ + /** The Parametric EQ's tab (#367, #370): for which node it is showing, its bands and where to aim. */ eq: { - shown: () => currentEqEditor()?.shownFor ?? null, - bands: () => currentEqEditor()?.bands() ?? null, - picked: () => currentEqEditor()?.picked ?? null, - handleAt: (index: number) => currentEqEditor()?.curve.handleGlobal(index) ?? null, - pointAt: (hz: number, db: number) => currentEqEditor()?.curve.pointGlobal(hz, db) ?? null, + shown: () => (editor.tab === 'eq' ? editor.eqEditor.shownFor : null), + bands: () => editor.eqEditor.bands(), + picked: () => editor.eqEditor.picked, + handleAt: (index: number) => editor.eqEditor.curve.handleGlobal(index), + pointAt: (hz: number, db: number) => editor.eqEditor.curve.pointGlobal(hz, db), fields: () => { - const eq = currentEqEditor(); - return ( - eq && { - type: eq.typeButton.text, - slope: eq.slopeButton.enabled ? eq.slopeButton.text : null, - gain: eq.gainField.enabled ? eq.gainField.value : null, - q: eq.qField.enabled ? eq.qField.value : null, - frequency: eq.frequencyField.value, - } - ); - }, - typeAt: () => { - const eq = currentEqEditor(); - return eq ? centreOf(eq.typeButton) : null; - }, - closeAt: () => { - const eq = currentEqEditor(); - return eq ? centreOf(eq.closeButton) : null; + const eq = editor.eqEditor; + return { + type: eq.typeButton.text, + slope: eq.slopeButton.enabled ? eq.slopeButton.text : null, + gain: eq.gainField.enabled ? eq.gainField.value : null, + q: eq.qField.enabled ? eq.qField.value : null, + frequency: eq.frequencyField.value, + }; }, + typeAt: () => centreOf(editor.eqEditor.typeButton), }, instrument: { current: () => editor.instrumentEditor.currentAssetId, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index ccf6a291..95aa6798 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -31,7 +31,6 @@ import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { soundFxBuildFor } from '../../core/soundfx/compile'; import { SoundFxParametricEq } from '../../core/soundfx/nodes'; -import { openEqEditor } from '../soundfx/EqEditor'; import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile'; import { masterRoutingProblems } from '../../core/music/projectRouting'; import { routingProblems } from '../../core/music/routing'; @@ -672,7 +671,7 @@ export class GraphCanvas extends Widget { private openCalledBehavior(node: GraphNode): boolean { if (node.type === SoundFxParametricEq.type) { - openEqEditor(this.state, this.assetId, node.id); + this.state.openEq(this.assetId, node.id); return true; } // A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already. diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts index ccf5182f..b64de93d 100644 --- a/src/editor/soundfx/EqEditor.ts +++ b/src/editor/soundfx/EqEditor.ts @@ -1,6 +1,5 @@ /** - * The editor of a Parametric EQ node (#367): a window that floats over the editor and leaves it - * usable, so the tab's Play can be pressed while the bands are set. The curve is the sum of the + * The editor of a Parametric EQ node (#367), in a tab of its own (#370). The curve is the sum of the * bands, worked out as Web Audio plays them (`parametricEq.ts`); a band is dragged for frequency and * gain, wheeled for its Q, and its numbers can be typed below the curve. A drag or a scrub is one * undo step. @@ -8,7 +7,7 @@ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, type Text } from 'pixi.js'; import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import type { GraphNode } from '../../core/graph'; -import { findGraphAsset } from '../../core/model/Project'; +import { findGraphAsset, type Project } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import { type EqBand, @@ -29,6 +28,7 @@ import { newEqBand, withBandSet, } from '../../core/soundfx/parametricEq'; +import { SoundFxParametricEq } from '../../core/soundfx/nodes'; import { Button, crispText, @@ -37,16 +37,16 @@ import { Label, Menu, NumberField, - Popup, pressCount, Spacer, Theme, textStyle, - ui, + VStack, Widget, } from '../../ui'; import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; +import { TabHeader } from '../TabHeader'; import { bandAt, dbToY, @@ -61,8 +61,9 @@ import { yToDb, } from './eqView'; -const CURVE_WIDTH = 640; -const CURVE_HEIGHT = 260; +/** The least the curve shrinks to; otherwise it takes the tab. */ +const MIN_CURVE_WIDTH = 320; +const MIN_CURVE_HEIGHT = 160; const HANDLE_RADIUS = 7; const GRAB_RADIUS = HANDLE_RADIUS + 4; /** Pixels of travel before a press on a band becomes a drag. */ @@ -73,7 +74,7 @@ const BAND_COLORS = [ 0xf2c84b, 0x6fcf97, 0x56ccf2, 0xe57373, 0xb066ff, 0xf2a75c, 0xff9ad5, 0x9b8cff, 0x3fb8c9, 0xd4c25a, ]; -/** What the curve asks of the window around it. */ +/** What the curve asks of the tab around it. */ interface EqHost { bands(): EqBand[]; picked: number; @@ -94,8 +95,8 @@ class EqCurve extends Widget { constructor(private readonly host: EqHost) { super(); - this.minWidth = CURVE_WIDTH; - this.minHeight = CURVE_HEIGHT; + this.minWidth = MIN_CURVE_WIDTH; + this.minHeight = MIN_CURVE_HEIGHT; this.cursor = 'crosshair'; this.addChild(this.bg, this.grid, this.lines, this.handles); const muted = textStyle('small').clone(); @@ -271,10 +272,10 @@ class EqCurve extends Widget { } /** - * One window for the node last double-clicked. It closes with its node, with the project, or by its - * own button. + * The tab of a Parametric EQ node (#370): one editor, pointed at the node whose tab is showing. It + * follows its node; once the node is gone the editor tells the tab row through `onGone`. */ -export class EqWindow extends Popup implements EqHost { +export class EqEditor extends VStack implements EqHost { readonly curve: EqCurve; private assetId = ''; private nodeId = ''; @@ -283,7 +284,8 @@ export class EqWindow extends Popup implements EqHost { private before: PropertyValue | undefined | null = null; private readonly title = new Label('Parametric EQ', { style: 'bodyBold' }); private readonly count = new Label('', { style: 'muted' }); - private readonly header = new HStack({ gap: Theme.gap, align: 'center' }); + private readonly header = new TabHeader(); + private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 }); private readonly bandLabel = new Label('', { style: 'small' }); readonly typeButton: Dropdown; readonly slopeButton: Dropdown; @@ -291,37 +293,17 @@ export class EqWindow extends Popup implements EqHost { readonly gainField: NumberField; readonly qField: NumberField; readonly removeButton: Button; - readonly closeButton: Button; - private readonly unsubscribe: (() => void)[] = []; private readonly perFrame: FramePreview; - /** The texts the window was last laid out for; a drag changes only numbers and needs no layout. */ + /** The texts the tab was last laid out for; a drag changes only numbers and needs no layout. */ private laidOut = ''; - private grab: { x: number; y: number } | null = null; constructor(private readonly state: EditorState) { - super({ backdrop: false, padding: Theme.padding * 2, gap: Theme.padding }); + super(); this.perFrame = new FramePreview(state, 'eq-band'); this.curve = new EqCurve(this); - this.closeButton = new Button('Close', { variant: 'ghost', onClick: () => this.close() }); - const header = this.header; - header.add(this.title); - header.add(this.count); - header.add(new Spacer(), 1); - header.add(this.closeButton); - header.cursor = 'move'; - header.on('pointerdown', (e: FederatedPointerEvent) => { - if (e.target !== header) return; - this.grab = { x: e.global.x - this.x, y: e.global.y - this.y }; - }); - this.on('globalpointermove', (e: FederatedPointerEvent) => { - if (!this.grab) return; - const screen = ui().app.screen; - this.position.set( - Math.round(Math.max(0, Math.min(screen.width - this.w, e.global.x - this.grab.x))), - Math.round(Math.max(0, Math.min(screen.height - this.h, e.global.y - this.grab.y))), - ); - }); - this.onPointerRelease(() => (this.grab = null)); + this.header.add(this.title); + this.header.add(this.count); + this.header.add(new Spacer(), 1); const numbers = (change: (value: number) => Partial) => ({ onInput: (value: number) => this.previewPicked(change(value)), @@ -369,48 +351,47 @@ export class EqWindow extends Popup implements EqHost { row.add(new Spacer(), 1); row.add(this.removeButton); - this.content.add(header); - this.content.add(this.curve); - this.content.add(row); - this.content.add( - new Label( - `Double-click to add a band (up to ${MAX_EQ_BANDS}). Drag a band for its frequency and gain, turn the mouse wheel for its Q, right-click it to remove it.`, - { style: 'muted' }, - ), + this.body.add(this.curve, 1); + this.body.add(row); + this.body.add( + new Label('Double-click: add a band. Drag: frequency and gain. Wheel: Q. Right-click: remove.', { + style: 'muted', + }), ); + this.add(this.header); + this.add(this.body, 1); + state.on('project', () => this.sync()); } - /** Opens for a node, or turns an open window to it. */ + /** Points the tab at a node; the band picked stays when it is the same node again. */ show(assetId: string, nodeId: string): void { - const other = assetId !== this.assetId || nodeId !== this.nodeId; - this.assetId = assetId; - this.nodeId = nodeId; - if (other) this.picked = this.bands().length > 0 ? 0 : -1; - if (!this.isOpen) { - this.unsubscribe.push( - this.state.on('project', () => this.sync()), - this.state.on('load', () => this.close()), - ); - this.openCentered(); + if (assetId !== this.assetId || nodeId !== this.nodeId) { + this.perFrame.cancel(); + this.before = null; + this.assetId = assetId; + this.nodeId = nodeId; + this.picked = this.bands().length > 0 ? 0 : -1; } this.sync(); } - get shownFor(): { assetId: string; nodeId: string } | null { - return this.isOpen ? { assetId: this.assetId, nodeId: this.nodeId } : null; + /** The tab is closed: the editor lets go of its node. */ + close(): void { + this.perFrame.cancel(); + const node = this.node(); + if (node) this.restore(node); + this.assetId = ''; + this.nodeId = ''; + this.picked = -1; + this.laidOut = ''; } - override close(): void { - for (const off of this.unsubscribe.splice(0)) off(); - this.perFrame.cancel(); - this.laidOut = ''; - super.close(); + get shownFor(): { assetId: string; nodeId: string } | null { + return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null; } private node(): GraphNode | null { - return ( - findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find((n) => n.id === this.nodeId) ?? null - ); + return eqNodeOf(this.state.project, this.assetId, this.nodeId); } bands(): EqBand[] { @@ -471,7 +452,8 @@ export class EqWindow extends Popup implements EqHost { if (this.picked >= 0) this.commit(withBandSet(this.bands(), this.picked, change)); } - private removePicked(): void { + /** Delete in the tab takes the picked band – never the node the tab is about. */ + removePicked(): void { const index = this.picked; if (index < 0) return; this.picked = -1; @@ -500,19 +482,13 @@ export class EqWindow extends Popup implements EqHost { new Menu(items).openBelow(this.slopeButton); } - /** Follows the node: gone, the window goes with it. */ private sync(): void { - if (!this.isOpen) return; const node = this.node(); - if (!node) { - this.close(); - return; - } + if (!node) return; const bands = eqBandsOf(node.params); if (this.picked >= bands.length) this.picked = -1; const band = bands[this.picked]; - const name = findGraphAsset(this.state.project, this.assetId)?.name ?? ''; - const title = `Parametric EQ – ${name}`; + const title = `Parametric EQ – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`; const count = `${bands.length} / ${MAX_EQ_BANDS} bands`; const bandLabel = band ? `Band ${this.picked + 1}` : 'No band'; this.title.setText(title); @@ -532,23 +508,25 @@ export class EqWindow extends Popup implements EqHost { const texts = [title, count, bandLabel].join('\n'); if (texts !== this.laidOut) { this.laidOut = texts; - this.header.relayout(); - this.content.relayout(); + this.relayout(); } this.curve.invalidate(); } } -let eqWindow: EqWindow | null = null; - -/** Opens the editor of a Parametric EQ node; one window, turned to whichever node asked last. */ -export function openEqEditor(state: EditorState, assetId: string, nodeId: string): EqWindow { - eqWindow ??= new EqWindow(state); - eqWindow.show(assetId, nodeId); - return eqWindow; +/** The Parametric EQ node a tab is about, or null once it, or its asset, is gone. */ +export function eqNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null { + const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId); + return node?.type === SoundFxParametricEq.type ? node : null; } -/** The open window, for the check scripts. */ -export function currentEqEditor(): EqWindow | null { - return eqWindow?.isOpen ? eqWindow : null; +/** + * What an EQ's tab says: its asset's name, and which EQ it is when the asset has more than one. + */ +export function eqTabLabel(project: Project, assetId: string, nodeId: string): string { + const asset = findGraphAsset(project, assetId); + if (!asset) return ''; + const eqs = asset.graph.nodes.filter((n) => n.type === SoundFxParametricEq.type); + const index = eqs.findIndex((n) => n.id === nodeId); + return eqs.length > 1 ? `${asset.name} · EQ ${index + 1}` : `${asset.name} · EQ`; } diff --git a/src/editor/state.ts b/src/editor/state.ts index abacf7bc..86d5d8cc 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -226,6 +226,8 @@ export type EditorEvent = | 'selection' /** A Variables tab was asked for (`openedValues`). */ | 'values' + /** A Parametric EQ's tab was asked for (`openedEq`). */ + | 'eq' /** An asset was opened (double-click, Enter, or just created): its tab should show. */ | 'open' /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ @@ -254,6 +256,8 @@ export class EditorState { selection: Selection = null; /** The last target asked for by `openValues`; the tabs themselves are the editor's. */ openedValues: ValuesTarget | null = null; + /** The last Parametric EQ node asked for by `openEq`. */ + openedEq: { assetId: string; nodeId: string } | null = null; mode: EditorMode = 'edit'; /** * A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all @@ -317,6 +321,7 @@ export class EditorState { this.commands = new CommandStack(project); this.selection = null; this.openedValues = null; + this.openedEq = null; this.clipboard = null; this.patternPlaces.clear(); this.saveStatus = 'saved'; @@ -485,6 +490,12 @@ export class EditorState { this.emit('values'); } + /** Asks for the tab of a Parametric EQ node (#370), the way `openValues` asks for a Variables tab. */ + openEq(assetId: string, nodeId: string): void { + this.openedEq = { assetId, nodeId }; + this.emit('eq'); + } + /** * Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the * Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index c41c6e6b..6855655a 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -523,6 +523,7 @@ export async function sound(t) { // #367: a Parametric EQ behind the gain. A double-click on the node opens its editor, one on the // curve adds a peak there, a drag moves it as one undo step, the wheel narrows it, and the // voice passes one filter for it. + // #370: the editor is a tab of its own, and goes with its node. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-eq', 'SoundFxParametricEq', -240, 0))}); @@ -534,20 +535,23 @@ export async function sound(t) { const eqNode = await json("__gm.soundfx.nodeAt('fx-eq')"); await t.doubleClick(eqNode.x, eqNode.y); const eqShown = await json('__gm.eq.shown()'); + const eqDocs = await json('__gm.documents()'); const eqPeakAt = await json('__gm.eq.pointAt(1000, 6)'); await t.doubleClick(eqPeakAt.x, eqPeakAt.y); const eqAdded = await json('{ bands: __gm.eq.bands(), fields: __gm.eq.fields() }'); const bandNear = (band, hz, db) => band && Math.abs(Math.log2(band.frequency / hz)) < 0.05 && Math.abs(band.gain - db) < 0.5; check( - 'a double-click opens the Parametric EQ’s editor, and one on its curve adds a peak there', + 'a double-click opens the Parametric EQ in a tab, and one on its curve adds a peak there', eqShown?.nodeId === 'fx-eq' && + eqDocs.active === `eq:${fxId}:fx-eq` && + eqDocs.docs.includes(`asset:${fxId}`) && eqAdded.bands.length === 1 && eqAdded.bands[0].type === 'peaking' && bandNear(eqAdded.bands[0], 1000, 6) && eqAdded.fields.type === 'Peaking' && eqAdded.fields.slope === null, - JSON.stringify({ eqShown, eqAdded }), + JSON.stringify({ eqShown, eqDocs, eqAdded }), ); const eqHandle = await json('__gm.eq.handleAt(0)'); const eqTo = await json('__gm.eq.pointAt(4000, -6)'); @@ -568,23 +572,23 @@ export async function sound(t) { JSON.stringify({ eqDragged, eqUndone, eqWheeled }), ); await shot('34-parametric-eq'); + await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`); await evaluate('__gm.sound.stopAll()'); const eqBefore = await stats(); await click(fxPlayAt.x, fxPlayAt.y); const eqHeard = await waitFor(stats, (s) => s.started > eqBefore.started); await evaluate('__gm.sound.stopAll()'); - const eqClose = await json('__gm.eq.closeAt()'); - await click(eqClose.x, eqClose.y); - check( - 'the voice passes the gain and the band, and the editor closes by its button', - eqHeard.last?.stages === 2 && (await json('__gm.eq.shown()')) === null, - JSON.stringify(eqHeard.last), - ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-eq'); graph.edges = graph.edges.filter((e) => !['fx-e10', 'fx-e11'].includes(e.id)); graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`); + const eqDocsAfter = await json('__gm.documents()'); + check( + 'the voice passes the gain and the band, and the EQ’s tab goes with its node', + eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')), + JSON.stringify({ last: eqHeard.last, eqDocsAfter }), + ); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;