Make Design View membership edits undoable: the view_state write moves inside the block
All four actions share one body; the point turns on a model diff, so a no-op leaves history and the Undo button alone. The Save-As prompt stays outside.
This commit is contained in:
@@ -36,6 +36,18 @@ is owned by other directories and only skinned here.
|
||||
reclaim span is open; the mechanism and its compile-time enforcement are
|
||||
`src/shell/undo/CLAUDE.md`'s. Bank index verbs additionally stay one batched
|
||||
`UNDO_STATE_MISCCFG` point apiece, so one bank operation is one Ctrl-Z.
|
||||
- **A Design View membership edit's undo point holds its `view_state` write — so the
|
||||
persist runs INSIDE the block, and the point turns on a MODEL DIFF, not on "REAPER
|
||||
was written to".** All four membership actions (tag, untag / tag→Arrange, show-both,
|
||||
the item moves) share one body, `design_view_actions`' `runMembershipEdit`.
|
||||
Everything the reapply they drive writes is the four driven track flags, which mint
|
||||
no undo state at all (`src/shell/view/CLAUDE.md`), so a point closed BEFORE the
|
||||
ext-state write covered no change: the edit was not undoable and rode whatever point
|
||||
the user's next act minted. Two consequences bind. The persist's modal Save-As half
|
||||
is split off and runs before the block opens — a modal may not hold a block open.
|
||||
And a no-op edit records no label, because a recorded label re-titles the Undo button
|
||||
whether or not REAPER appends an entry. Same shape as `shell/bank_ops`'
|
||||
`persistBankOp`, for the same reason.
|
||||
- **The prune action is the ONLY file-deletion action in the system**; it opens no
|
||||
undo point (file deletion is not REAPER-undoable). It halts on
|
||||
`blockedByTracking` and prints whichever blockers fired — the malformed ledger,
|
||||
|
||||
@@ -104,16 +104,13 @@ std::vector<std::string> selectedTrackGuids() {
|
||||
}
|
||||
|
||||
// Reapplies the CURRENT active mode so a membership mutation takes visible effect
|
||||
// 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
|
||||
// immediately (park/unpark/re-derive parents). `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. 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;
|
||||
void reapplyActiveMode() {
|
||||
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
||||
}
|
||||
|
||||
constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant
|
||||
@@ -147,33 +144,45 @@ std::vector<RetagItem> selectedRetagItems() {
|
||||
// serialization. Every other action keeps the full save.
|
||||
enum class PersistScope { Full, ViewOnly };
|
||||
|
||||
// Persists the bank + Design-View model after every state-changing action so the
|
||||
// view model is not lost across save/close/reopen. If membership is non-empty and
|
||||
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) —
|
||||
// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
|
||||
void persistViewState(PersistScope scope) {
|
||||
if (!g_session->view().membership().empty()) {
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (proj) {
|
||||
auto readRppPath = [&]() -> std::string {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
};
|
||||
// The persist's MODAL half, split from the write below so a caller can run the two on
|
||||
// opposite sides of an undo block. If membership is non-empty and the project is
|
||||
// unsaved, prompts Save-As (mirrors the flow capture uses) — DAW-ONLY:
|
||||
// Main_SaveProject(proj, true) blocks until the dialog is dismissed. False means the
|
||||
// user declined and nothing may be written.
|
||||
bool ensurePersistTarget() {
|
||||
if (g_session->view().membership().empty()) return true;
|
||||
|
||||
if (readRppPath().empty()) {
|
||||
Main_SaveProject(proj, true);
|
||||
if (readRppPath().empty()) { // still empty -> user cancelled
|
||||
ShowConsoleMsg(
|
||||
"ReaSampler: Design View state will not persist until "
|
||||
"the project is saved.\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly();
|
||||
else g_session->saveToActiveProject();
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) return true; // no active project — the write below no-ops on its own
|
||||
|
||||
auto readRppPath = [&]() -> std::string {
|
||||
std::vector<char> buf(4096, '\0');
|
||||
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return std::string(buf.data());
|
||||
};
|
||||
|
||||
if (!readRppPath().empty()) return true;
|
||||
|
||||
Main_SaveProject(proj, true);
|
||||
if (!readRppPath().empty()) return true; // still empty -> user cancelled
|
||||
|
||||
ShowConsoleMsg("ReaSampler: Design View state will not persist until "
|
||||
"the project is saved.\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// The persist's WRITE half: the bank + Design-View model, so neither is lost across
|
||||
// save/close/reopen. Returns whether the write was ISSUED — false means no active
|
||||
// project or an unsaved one, with NOTHING written (session.h owns that contract), which
|
||||
// is the only reading a caller may turn into a discarded undo point.
|
||||
bool writePersistedState(PersistScope scope) {
|
||||
return scope == PersistScope::ViewOnly ? g_session->saveViewStateOnly()
|
||||
: g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// The two halves as a caller holding no undo block runs them.
|
||||
void persistViewState(PersistScope scope) {
|
||||
if (ensurePersistTarget()) writePersistedState(scope);
|
||||
}
|
||||
|
||||
// The footer segment already reads disabled while the transport runs, but an action can
|
||||
@@ -211,17 +220,53 @@ void doActivateMode(const std::string& modeId) {
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
// The block each membership-edit action below owns. The reapply they drive opens none
|
||||
// of its own — nothing it writes mints a point unaided — so a user-fired tag stays one
|
||||
// Ctrl-Z only because the ACTION spans it, and the label names the act the user
|
||||
// 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.
|
||||
// The drive half of a membership edit: what runs inside the block, answering whether
|
||||
// it made a project change of its own that a point must hold even with nothing
|
||||
// persisted. The reapply alone never does — everything it writes is the four driven
|
||||
// flags, which mint no undo state (src/shell/view/CLAUDE.md).
|
||||
bool driveReapply() {
|
||||
reapplyActiveMode();
|
||||
return false;
|
||||
}
|
||||
|
||||
// The item moves' drive: re-drive the same minting/apply path auto-tag uses — mint or
|
||||
// split lanes for any track whose items now span modes, then reassert active-mode lane
|
||||
// visibility. A mint moves items between lanes, which is a real project change whether
|
||||
// or not the persist below lands.
|
||||
bool driveMintLanesAndReapply() {
|
||||
const bool minted = mintManagedLanes(g_session->view(), nullptr);
|
||||
reapplyActiveMode();
|
||||
return minted;
|
||||
}
|
||||
|
||||
// The ONE body behind all four membership edits (this directory's CLAUDE.md owns the
|
||||
// rule; what follows is why the ORDER is what it is). `mutate` is a pure model edit —
|
||||
// nothing in it reaches REAPER.
|
||||
//
|
||||
// The point is recorded only where the reapply actually wrote: re-tagging tracks that
|
||||
// already sit in that mode writes nothing to the project, and the ("", 0) discard form
|
||||
// UndoBlock closes on by default is real from a hand-fired action (DAW-measured,
|
||||
// src/shell/view/CLAUDE.md). The membership move itself is never in the point either
|
||||
// way — persistViewState runs after the close.
|
||||
// The prompt sits between the edit and the block because it is pinned on both sides:
|
||||
// its condition reads the POST-edit membership (a first-ever tag on an unsaved project
|
||||
// must still prompt), and a modal may not stand inside an open block. The persist then
|
||||
// runs INSIDE, being the only thing here a point can hold; a re-tag of tracks already
|
||||
// in that mode re-writes byte-identical ext state, which is why the label turns on the
|
||||
// model diff and not on the write.
|
||||
template <class Mutate>
|
||||
void runMembershipEdit(Mutate&& mutate, bool (*drive)(), const std::string& label,
|
||||
int mask) {
|
||||
const MembershipIndex before = g_session->view().membership();
|
||||
mutate();
|
||||
const bool changed = !(g_session->view().membership() == before);
|
||||
|
||||
const bool persistable = ensurePersistTarget();
|
||||
|
||||
UndoBlock undo(nullptr);
|
||||
const bool structural = drive();
|
||||
const bool persisted = persistable && writePersistedState(PersistScope::Full);
|
||||
if (structural || (changed && persisted))
|
||||
undo.recordPoint(label, mask);
|
||||
// Otherwise the block closes on its ("", 0) discard form — real from a hand-fired
|
||||
// action (DAW-measured, src/shell/view/CLAUDE.md) — leaving history and the Undo
|
||||
// button both untouched.
|
||||
}
|
||||
|
||||
// tag() replaces any prior single-mode membership (a leaf lives in one mode; the
|
||||
// cross-mode case is show-both).
|
||||
@@ -229,13 +274,11 @@ void doTag(const std::string& modeId) {
|
||||
const std::vector<std::string> guids = selectedTrackGuids();
|
||||
if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems)
|
||||
|
||||
{
|
||||
UndoBlock undo(nullptr);
|
||||
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
|
||||
if (reapplyActiveMode())
|
||||
undo.recordPoint("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
|
||||
}
|
||||
persistViewState(PersistScope::Full);
|
||||
runMembershipEdit(
|
||||
[&] {
|
||||
for (const std::string& g : guids) g_session->view().membership().tag(g, modeId);
|
||||
},
|
||||
&driveReapply, "ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask);
|
||||
}
|
||||
|
||||
// Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence
|
||||
@@ -244,13 +287,11 @@ void doUntag() {
|
||||
const std::vector<std::string> guids = selectedTrackGuids();
|
||||
if (guids.empty()) return;
|
||||
|
||||
{
|
||||
UndoBlock undo(nullptr);
|
||||
for (const std::string& g : guids) g_session->view().membership().untag(g);
|
||||
if (reapplyActiveMode())
|
||||
undo.recordPoint("ReaSampler: untag selected tracks", kViewUndoMask);
|
||||
}
|
||||
persistViewState(PersistScope::Full);
|
||||
runMembershipEdit(
|
||||
[&] {
|
||||
for (const std::string& g : guids) g_session->view().membership().untag(g);
|
||||
},
|
||||
&driveReapply, "ReaSampler: untag selected tracks", kViewUndoMask);
|
||||
}
|
||||
|
||||
// Flips each track's pin independently — the honest semantics of a toggle on a
|
||||
@@ -259,14 +300,12 @@ void doShowBoth() {
|
||||
const std::vector<std::string> guids = selectedTrackGuids();
|
||||
if (guids.empty()) return;
|
||||
|
||||
{
|
||||
UndoBlock undo(nullptr);
|
||||
MembershipIndex& m = g_session->view().membership();
|
||||
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
|
||||
if (reapplyActiveMode())
|
||||
undo.recordPoint("ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
|
||||
}
|
||||
persistViewState(PersistScope::Full);
|
||||
runMembershipEdit(
|
||||
[&] {
|
||||
MembershipIndex& m = g_session->view().membership();
|
||||
for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g));
|
||||
},
|
||||
&driveReapply, "ReaSampler: toggle show-both on selected tracks", kViewUndoMask);
|
||||
}
|
||||
|
||||
// Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange
|
||||
@@ -281,28 +320,18 @@ void doMoveItems(const std::string& targetMode) {
|
||||
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
|
||||
if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty
|
||||
|
||||
MembershipIndex& membership = g_session->view().membership();
|
||||
|
||||
{
|
||||
UndoBlock undo(nullptr);
|
||||
for (const ItemRetagOp& op : ops) {
|
||||
if (op.untag) membership.untag(op.guid);
|
||||
else membership.tag(op.guid, op.modeId);
|
||||
}
|
||||
// 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.
|
||||
// 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.recordPoint(targetMode.empty()
|
||||
? std::string("ReaSampler: untag selected items")
|
||||
: "ReaSampler: move selected items -> " + targetMode,
|
||||
-1);
|
||||
}
|
||||
|
||||
persistViewState(PersistScope::Full);
|
||||
runMembershipEdit(
|
||||
[&] {
|
||||
MembershipIndex& membership = g_session->view().membership();
|
||||
for (const ItemRetagOp& op : ops) {
|
||||
if (op.untag) membership.untag(op.guid);
|
||||
else membership.tag(op.guid, op.modeId);
|
||||
}
|
||||
},
|
||||
&driveMintLanesAndReapply,
|
||||
targetMode.empty() ? std::string("ReaSampler: untag selected items")
|
||||
: "ReaSampler: move selected items -> " + targetMode,
|
||||
-1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Reference in New Issue
Block a user