Collapse a Design View mode switch and its background FX parking to ONE undo point

applyMode now returns with its undo block open; the reclaimer closes it when the drain
settles. A transport deferral or a tab switch ends it early -- two points, never a leak.
This commit is contained in:
2026-08-07 12:53:42 -04:00
parent 2173263bb9
commit 86aa8dabba
7 changed files with 449 additions and 50 deletions
+52 -9
View File
@@ -38,6 +38,28 @@ FxReclaimQueue g_queue;
// A release applied to the in-memory model and not yet written — see ReleaseFlush.
ReleaseFlush g_releaseFlush;
// The one open undo block a mode switch and its parking share — see UndoSpan.
UndoSpan g_span;
// The ONE open and the ONE close. Both idempotent, so every caller may say either
// unconditionally; the close reads the span's values before clearing it, because the
// clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may
// find a span it could close a second time.
void openSpan(ReaProject* proj, const char* label, int mask) {
if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj);
}
void closeSpan() {
if (!g_span.open()) return;
ReaProject* const proj = g_span.project();
const std::string label = g_span.label();
const int mask = g_span.mask();
g_span.end();
// The span outlives its opener, so by here the project may be the one that just
// closed — its undo history went with it and there is no block left to end.
if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask);
}
// TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter
// the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a
// second queue for the same project and race the outer one's writes track for track.
@@ -132,16 +154,15 @@ struct ReclaimHost {
if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank
if (TrackFX_GetOffline(tr, fxIndex) == offline) return false; // already there
// DAW-VERIFIED: the same ten TrackFX_SetOffline calls mint ten undo points
// unblocked and ZERO inside this pair. One block per WRITE even though a step is
// now one whole track: the write itself is what pumps, so the block cannot avoid
// spanning a pump — it can only span as little of one as possible. Widening it to
// the track would multiply that exposure by the chain length while suppressing no
// extra undo point (the discard form mints none either way), and a user action
// landing inside a discarded block can lose its own Ctrl-Z.
Undo_BeginBlock2(project);
// Every write in the drain rides ONE span. Do not reintroduce a block per write:
// it minted one undo point per FX toggle, because the ("", 0) discard form
// coalesces rather than discards outside an action (see UndoSpan). The open is a
// no-op under a switch's span, which is why the FX bit is added separately —
// here, at the first real write, so a switch whose parking never matured closes
// on the flags-and-items mask it opened with and serializes no FX state.
openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX);
g_span.widen(UNDO_STATE_FX);
TrackFX_SetOffline(tr, fxIndex, offline);
Undo_EndBlock2(project, "", 0);
return true;
}
@@ -188,6 +209,8 @@ void invalidateFxReclaim() {
bool fxReclaimDrainInProgress() { return g_draining; }
void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
// Neither guard may close the span: both name a caller that is still on the stack and
// still owns it — the outer drain, or the applyMode that opened it. Both re-tick.
if (g_draining) return;
// The model is mid-rewrite and its records are being consumed out from under any
// plan derived from it. The apply invalidates on its way out, so nothing is lost.
@@ -200,6 +223,12 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
// that gate that poll.
ReaProject* project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) project = nullptr;
// A span belongs to the project it was opened on. Keyed on the SPAN's own project
// rather than the queue's pin, which moves independently: a tab switch, a closed
// project, or no active project at all ends it where it stands rather than carrying
// it into another project's history — and the no-project arm below returns.
if (g_span.open() && g_span.project() != project) closeSpan();
if (!g_queue.beginTick(project)) return;
// DERIVED, never persisted: recomputed from view state every time the queue was
@@ -213,9 +242,20 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) {
ReclaimHost host{project, &session};
const ReclaimRunResult run = g_queue.run(host);
reportDrops(project, run.drops);
if (spanEndsAfter(run)) closeSpan();
if (!run.aborted && run.settled) g_queue.goIdle();
}
void fxReclaimBeginUndoSpan(ReaProject* proj) {
openSpan(proj, "", 0);
}
void fxReclaimLabelUndoSpan(const char* label, int mask) {
g_span.relabel(label ? label : "", mask);
}
void fxReclaimEndUndoSpan() { closeSpan(); }
void fxReclaimFlushRelease(ReaSamplerSession& session) {
// GetCurrentProjectInLoadSave() names the project THIS hook is being asked to save
// (reaper_plugin_functions.h:1582-1587) -- not merely the active tab, which a
@@ -224,6 +264,9 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) {
// the project actually being written, or match the arm against the active tab while a
// different project saved and write into that wrong project's ext state.
ReaProject* saving = GetCurrentProjectInLoadSave();
// BEFORE the arm test, and unconditionally: a save is a user act the span must not
// absorb, and the arm is not what decides whether one is open.
closeSpan();
if (!g_releaseFlush.pendingFor(saving)) return;
// Promptless, unlike design_view_actions' persistViewState, which can open a modal
// Save-As. [verify — DAW] whether this write reaches the <EXTSTATE> block of the