From 8c0d1cfd3f80d91e1b6e1758aebfa4f477093be4 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 8 Oct 2026 12:13:23 +0200 Subject: [PATCH] Snap the routing graph of a song with the header's magnet (#350) The routing of a song is the same graph editor as a sound FX, but its tab had no magnet, so its nodes could not be put on the grid. - The Song tab carries the magnet next to Routing. It is off at first and acts on the routing graph. - It is greyed out on the timeline, as Grid is greyed out in the routing. Switching the view only updates these two buttons, so a playing song is not handed its sequence again. - The check hooks give the magnet and the snapping of the routing. Tests: - e2e 65 (melektaus-atx): ALL PASSED, 52 checks (1 new) - Counter-check (magnet enabled on the timeline): exactly this check red - Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests Co-Authored-By: Claude Opus 5.5 --- src/editor/debug/songHooks.ts | 4 +++- src/editor/song/SongEditor.ts | 12 +++++++++++- tools/e2e/65-song.mjs | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/editor/debug/songHooks.ts b/src/editor/debug/songHooks.ts index 4635e6cd..6d6192d1 100644 --- a/src/editor/debug/songHooks.ts +++ b/src/editor/debug/songHooks.ts @@ -1,7 +1,7 @@ import { findAssetOf } from '../../core/model/Project'; import type { HeadPart } from '../song/songView'; import { soundLibrary } from '../../viewport/SoundLibrary'; -import { centreOf, type HookContext } from './hookHelpers'; +import { centreOf, snapButton, type HookContext } from './hookHelpers'; /** The Song tab and its tracks (#334). */ export function songHooks({ state, editor }: HookContext) { @@ -59,6 +59,8 @@ export function songHooks({ state, editor }: HookContext) { edgeAt: (edgeId: string) => songEditor.routing.edgeGlobal(edgeId), label: (nodeId: string) => songEditor.routing.nodeLabel(nodeId), problem: (nodeId: string) => songEditor.routing.nodeProblem(nodeId), + snapping: () => songEditor.routing.snapEnabled, + snapButton: () => ({ ...snapButton(songEditor.snap), enabled: songEditor.snap.button.enabled }), }, }, }; diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index 2cef0f87..04b67f9a 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -37,6 +37,7 @@ import { import { activateAsset } from '../actions'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; +import { SnapToggle } from '../graph/SnapToggle'; import { claimTransport, releaseTransport } from '../music/transport'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; @@ -84,6 +85,7 @@ export class SongEditor extends AssetTabEditor { readonly timelineButton: Button; readonly routingButton: Button; readonly routing: GraphCanvas; + readonly snap = new SnapToggle(() => this.routing); private shown: SongView = 'timeline'; private readonly pane = new VStack(); /** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */ @@ -160,6 +162,7 @@ export class SongEditor extends AssetTabEditor { this.mount(timeline, [ this.timelineButton, this.routingButton, + ...this.snap.widgets, this.playButton, this.recordButton, this.inputButton, @@ -192,9 +195,16 @@ export class SongEditor extends AssetTabEditor { this.routingButton.setToggled(view === 'routing'); this.pane.relayout(); if (view === 'routing') this.routing.show(this.canvas.currentAssetId); + this.syncViewButtons(this.asset()); this.syncPlayButton(); } + /** The grid is the timeline's, the snap the routing's. */ + private syncViewButtons(asset: SongAsset | null): void { + this.gridButton.setEnabled(asset !== null && this.shown === 'timeline'); + this.snap.setEnabled(asset !== null && this.shown === 'routing'); + } + protected override body(canvas: SongTimeline): Widget { this.pane.add(canvas, 1); this.pane.add(this.routing, 1); @@ -525,7 +535,7 @@ export class SongEditor extends AssetTabEditor { protected override syncExtras(asset: SongAsset | null): void { if (this.routing.currentAssetId !== (asset?.id ?? '')) this.routing.show(asset?.id ?? ''); this.fitButton.setEnabled(asset !== null); - this.gridButton.setEnabled(asset !== null && this.shown === 'timeline'); + this.syncViewButtons(asset); this.syncPlayButton(); const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar'; this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar'); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index b65fe1a3..550a12a3 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -890,9 +890,27 @@ export async function song(t) { song.tracks.forEach((track) => Object.assign(track, { mute: false, solo: false, fx: '' }));`); await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`); await sleep(200); + const magnetOnTimeline = await json('__gm.song.routing.snapButton()'); p = await json("__gm.song.viewAt('routing')"); await click(p.x, p.y); await sleep(300); + // The magnet is the routing's, as in every graph tab: off at first, greyed out on the timeline. + const magnet = await json('__gm.song.routing.snapButton()'); + const snapOff = await json('__gm.song.routing.snapping()'); + await click(magnet.x, magnet.y); + const snapOn = await json('__gm.song.routing.snapping()'); + const lit = (await json('__gm.song.routing.snapButton()')).toggled; + await click(magnet.x, magnet.y); + check( + 'the magnet in the header switches snapping in the routing on and off', + !magnetOnTimeline.enabled && + magnet.enabled && + snapOff === false && + snapOn === true && + lit && + (await json('__gm.song.routing.snapping()')) === false, + JSON.stringify({ magnetOnTimeline, magnet, snapOff, snapOn, lit }), + ); const routingOf = () => json(`__gm.song.routing.graph(${JSON.stringify(id)})`); // The tracks as they are now: more than in 65a. const ids = (await model()).tracks.map((track) => track.id);