Design View: no undo block may open over the reclaim span, and a reapply closes none

Every opener ends the span first, so a tag fired mid-drain no longer hijacks the
switch's point. One stated criterion governs both lane sites; no-op actions discard.
This commit is contained in:
2026-08-07 15:13:52 -04:00
parent 39529a5ac4
commit b2f6b3f1d0
9 changed files with 210 additions and 76 deletions
+45 -15
View File
@@ -25,6 +25,7 @@
#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint
#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span
#include "shell/view/view.h" // applyMode (D2 shell)
#include "shell/view/view_lanes.h" // mintManagedLanes
#include "shell/view/view_park.h" // kViewUndoMask
@@ -105,12 +106,16 @@ std::vector<std::string> selectedTrackGuids() {
}
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
// immediately (park/unpark/re-derive parents). The return is deliberately discarded: a
// refused reapply leaves the membership edit standing and unapplied, and the user's next
// edit or mode switch reapplies it. What must NOT be dropped with it is the reclaimer's
// queue, which is why applyMode invalidates on its refusal path too (view.cpp).
void reapplyActiveMode() {
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
// immediately (park/unpark/re-derive parents), answering whether it wrote any project
// state — the caller's block mints only if something did. `applied` is deliberately
// discarded: a refused reapply leaves the membership edit standing and unapplied, and
// the user's next edit or mode switch reapplies it (a refusal writes nothing, so it
// reports false here and the block discards). What must NOT be dropped with it is the
// reclaimer's queue, which is why applyMode invalidates on its refusal path too
// (view.cpp).
bool reapplyActiveMode() {
return applyMode(g_session->view(), g_session->view().activeModeId(), nullptr)
.wroteProjectState;
}
constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant
@@ -189,15 +194,21 @@ void doToggleMode() {
const std::string target =
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
if (target.empty()) return; // no modes to cycle to (degenerate)
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
if (!applyMode(g_session->view(), target, nullptr).applied) {
reportModeSwitchRefused();
return;
}
persistViewState(PersistScope::ViewOnly);
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
}
// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
// Direct jump to a named mode. applyMode is a no-op (`applied` false, no mutation) if
// the id is unregistered or the transport is running, so both fail safe.
void doActivateMode(const std::string& modeId) {
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
if (!applyMode(g_session->view(), modeId, nullptr).applied) {
reportModeSwitchRefused();
return;
}
persistViewState(PersistScope::ViewOnly);
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
}
@@ -208,18 +219,34 @@ void doActivateMode(const std::string& modeId) {
// actually fired rather than the reapply it happened to trigger. Every one of them
// closes the block before persistViewState, which can pop a modal Save-As. Stack RAII
// so a throw between the two calls cannot leave the block open in the user's project.
//
// ENDING THE RECLAIM SPAN IS PART OF OPENING, and every action here inherits it from
// this one constructor. A mode switch fired seconds ago has left the span open; a block
// opened over it makes each close pair with the other's open, collapsing the switch
// into this tag — mechanism and full rule in src/shell/view/CLAUDE.md.
//
// `markWrote` decides whether this block mints at all: re-tagging tracks that already
// sit in that mode writes nothing to the project, and the ("", 0) discard form is real
// from a hand-fired action (DAW-measured, same doc). The membership move itself is
// never in the point either way — persistViewState runs after the close.
class ActionUndoBlock {
public:
ActionUndoBlock(std::string label, int mask) : label_(std::move(label)), mask_(mask) {
fxReclaimEndUndoSpan();
Undo_BeginBlock2(nullptr);
}
~ActionUndoBlock() { Undo_EndBlock2(nullptr, label_.c_str(), mask_); }
~ActionUndoBlock() {
if (wrote_) Undo_EndBlock2(nullptr, label_.c_str(), mask_);
else Undo_EndBlock2(nullptr, "", 0);
}
void markWrote() { wrote_ = true; }
ActionUndoBlock(const ActionUndoBlock&) = delete;
ActionUndoBlock& operator=(const ActionUndoBlock&) = delete;
private:
std::string label_;
int mask_;
bool wrote_ = false;
};
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
@@ -231,7 +258,7 @@ void doTag(const std::string& modeId) {
{
ActionUndoBlock undo("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
reapplyActiveMode();
if (reapplyActiveMode()) undo.markWrote();
}
persistViewState(PersistScope::Full);
}
@@ -245,7 +272,7 @@ void doUntag() {
{
ActionUndoBlock undo("ReaSampler: untag selected tracks", kViewUndoMask);
for (const std::string& g : guids) g_session->view().membership().untag(g);
reapplyActiveMode();
if (reapplyActiveMode()) undo.markWrote();
}
persistViewState(PersistScope::Full);
}
@@ -260,7 +287,7 @@ void doShowBoth() {
ActionUndoBlock undo("ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
MembershipIndex& m = g_session->view().membership();
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
reapplyActiveMode();
if (reapplyActiveMode()) undo.markWrote();
}
persistViewState(PersistScope::Full);
}
@@ -290,8 +317,11 @@ void doMoveItems(const std::string& targetMode) {
}
// Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any
// track whose items now span modes, then reassert active-mode lane visibility.
mintManagedLanes(g_session->view(), nullptr);
reapplyActiveMode();
// Either half writing is enough to mint; a retag that split no lanes and drove
// no flag leaves nothing for a point to hold.
const bool minted = mintManagedLanes(g_session->view(), nullptr);
const bool reapplied = reapplyActiveMode();
if (minted || reapplied) undo.markWrote();
}
persistViewState(PersistScope::Full);