Edit a Parametric EQ in a tab of its own (#370)
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Successful in 15m17s

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:<asset>:<node>`, 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 13:45:02 +02:00
1 parent db15422372
commit 9ab1bbc507
6 files changed
+150 -129

No files matched your search

+43 -4
View File
@@ -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<CentreTab, FixedTab | 'variables' | 'routing' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'eq' | 'management'>;
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<CentreTab, string> = {
container: 'Container',
variables: 'Variables',
routing: 'Master routing',
eq: 'Parametric EQ',
management: 'User management',
};
@@ -206,7 +210,13 @@ const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
};
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<DocumentId, ValuesTarget>();
/** The node behind each EQ tab (#370). */
private readonly eqTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** 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;