// actions.cpp — the Design View action family (Phase D4). See actions.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers // (CLAUDE.md §contract). The action ids are minted from FOREVER-STABLE strings (the // same CEREBELLUM_REASAMPLER_ family prefix main.cpp uses); user keybindings key off // them, so they must never change after ship. // // Each action: // 1. mutates the session's ViewModeModel (membership tag/untag/show-both, or the // active mode via toggle/activate) — the pure D1 state, // 2. reapplies the active mode through the D2 view shell (applyMode) so the change // takes visible effect immediately (tagging a track into Design while in Arrange // parks it right away; a mode change re-partitions and re-parks in one step). // // Selection-driven mutations iterate the CURRENT REAPER track selection // (CountSelectedTracks/GetSelectedTrack — both ignore the master, which is correct: // the master is never tagged) and resolve each track to its canonical GUID key via // the shared guidString helper, so the keys match exactly what the D2 shell / view // tree key on (the cross-module key contract). #include "actions.h" #include #include #include #include "app_version.h" // channelCommandId / channelActionName — one channel-identity point #include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) #include "bank_panel.h" // selection seam + full-height toggles (B3/B4) #include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "lane_keys.h" // isOnManualLane — the single managed/manual predicate #include "persist.h" // ReaSamplerSession (owns book() + view() model) #include "track_guid.h" // shared MediaTrack* -> canonical GUID key #include "view.h" // applyMode + mintManagedLanes (D2 shell) #include "view_mode_model.h" #include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs) #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_GetSelectedTrack #define REAPERAPI_WANT_CountSelectedMediaItems #define REAPERAPI_WANT_GetSelectedMediaItem #define REAPERAPI_WANT_GetMediaItemTrack #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { namespace { // FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended // at register time via channelCommandId (app_version), so stable rebuilds the exact shipped // id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family // id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a // persistent command id user keybindings key off — NEVER change a shipped suffix after ship. constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; constexpr const char* kIdUntag = "VIEW_UNTAG"; constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; // D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same // FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; // The live session the actions mutate. Set once by designViewRegisterActions and // read by the hookcommand handler. Not owned here (main.cpp owns g_session). ReaSamplerSession* g_session = nullptr; // Minted command ids (0 until registration succeeds). Compared in the handler. int g_cmdToggleMode = 0; int g_cmdActivateArrange = 0; int g_cmdActivateDesign = 0; int g_cmdTagDesign = 0; int g_cmdTagArrange = 0; int g_cmdUntag = 0; int g_cmdShowBoth = 0; int g_cmdMoveItemsDesign = 0; int g_cmdMoveItemsArrange = 0; int g_cmdUntagItems = 0; // gaccel storage must outlive registration — REAPER holds each pointer until we // mirror-unregister it. One per action. gaccel_register_t g_accelToggleMode{}; gaccel_register_t g_accelActivateArrange{}; gaccel_register_t g_accelActivateDesign{}; gaccel_register_t g_accelTagDesign{}; gaccel_register_t g_accelTagArrange{}; gaccel_register_t g_accelUntag{}; gaccel_register_t g_accelShowBoth{}; gaccel_register_t g_accelMoveItemsDesign{}; gaccel_register_t g_accelMoveItemsArrange{}; gaccel_register_t g_accelUntagItems{}; // Durable store of composed, channel-qualified strings (ids + labels). A std::deque never // invalidates references on push_back, so a c_str() handed to REAPER (a command_id at // register, a gaccel desc for its lifetime) stays valid until process exit. Memoized by // suffix so register and the mirror-unregister get the SAME id pointer for a given action. std::deque g_strStore; // Returns the channel-qualified command id for `suffix`, interning it once. Called by BOTH // registerAction and the unregister path, so a '-command_id' presents the identical string. const char* channelIdFor(const char* suffix) { const std::string composed = channelCommandId(suffix); for (const std::string& s : g_strStore) if (s == composed) return s.c_str(); g_strStore.push_back(composed); return g_strStore.back().c_str(); } // Mints a command id from a channel-qualified SUFFIX and registers its gaccel (Actions-list // entry with a channel-qualified label PHRASE). Returns the command id (0 on failure). Both // the composed id and label are interned durably (g_strStore) — REAPER holds the desc // pointer, and the id must survive to the mirror-unregister. The gaccel storage itself is // caller-owned (the file-scope g_accel* above). int registerAction(reaper_plugin_info_t* rec, const char* suffix, gaccel_register_t& accel, const char* phrase) { const char* id = channelIdFor(suffix); const int cmd = rec->Register("command_id", (void*)id); if (cmd) { g_strStore.push_back(channelActionName(phrase)); accel.accel.cmd = cmd; accel.desc = g_strStore.back().c_str(); rec->Register("gaccel", (void*)&accel); } return cmd; } // Collects the canonical GUID keys of the current track selection. Empty if nothing // is selected. CountSelectedTracks/GetSelectedTrack ignore the master (SDK), which is // exactly right — the master is never a tagged leaf. std::vector selectedTrackGuids() { std::vector guids; const int n = CountSelectedTracks(nullptr); // nullptr = active project guids.reserve(static_cast(n < 0 ? 0 : n)); for (int i = 0; i < n; ++i) { MediaTrack* tr = GetSelectedTrack(nullptr, i); if (!tr) continue; std::string g = guidString(tr); if (!g.empty()) guids.push_back(std::move(g)); } return guids; } // Reapplies the model's CURRENT active mode to the active project so a membership // mutation takes visible effect immediately (park/unpark/re-derive parents). Called // after every tag/untag/show-both. `proj = nullptr` -> REAPER's active project. void reapplyActiveMode() { applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); } // Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to // decide whether an item's lane name is meaningful for the manual-lane read. constexpr int kFreeModeFixedLanes = 2; // Collects the current media-item selection as the pure decision's input: each selected // item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned). // The manual-lane read follows the shared pure predicate exactly as the shell's readers // do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal // track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped. // Items whose GUID cannot be read are dropped (an empty GUID must never be retagged). std::vector selectedRetagItems() { std::vector items; const int n = CountSelectedMediaItems(nullptr); // nullptr = active project items.reserve(static_cast(n < 0 ? 0 : n)); for (int i = 0; i < n; ++i) { MediaItem* it = GetSelectedMediaItem(nullptr, i); if (!it) continue; std::string g = itemGuid(it); if (g.empty()) continue; MediaTrack* tr = GetMediaItemTrack(it); const bool fixedLane = tr && static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; // Only read the lane name on a fixed-lane track; the pure predicate handles the // normal-track case (returns false) so we pass an empty name and skip the read. const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{}; items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)}); } return items; } // Persists both the bank and the Design-View model to the active project's ext // state. Called after every state-changing Design View action so the view model // is not lost across save/close/reopen. Marking the project dirty is correct — // a Design View mutation is a project-level change the user should be prompted // to save. // // When the membership index is non-empty AND the project is unsaved, we prompt // the user to Save-As before persisting — mirroring the flow capture uses. // Gate: if membership is empty (no tracks tagged), skip the prompt entirely; // saveToActiveProject will no-op for an unsaved project, which is correct. // // DAW-ONLY ASSUMPTION: Main_SaveProject(proj, true) opens a Save-As dialog and // blocks until the user dismisses it. The blocking behaviour and dialog // appearance can only be confirmed in a running REAPER (same caveat as capture). void persistViewState() { if (!g_session->view().membership().empty()) { // At least one track is tagged — worth persisting. Check whether the // project is saved and, if not, prompt Save-As so saveToActiveProject // can write ext state. Mirrors capture's readRppPath idiom exactly. ReaProject* proj = EnumProjects(-1, nullptr, 0); if (proj) { auto readRppPath = [&]() -> std::string { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); return std::string(buf.data()); }; if (readRppPath().empty()) { // Project is unsaved — prompt Save-As. Main_SaveProject(proj, true); // Re-read: still empty means the user cancelled. if (readRppPath().empty()) { ShowConsoleMsg( "ReaSampler: Design View state will not persist until " "the project is saved.\n"); // The in-session tag state is left as-is — the mode change // already applied and remains valid for this session. return; } } } } g_session->saveToActiveProject(); } // -- Action bodies --------------------------------------------------------- // Toggle: cycle to the next mode in ordinal order (Arrange <-> Design with two // seeds; scales to cycle-through-all for >2 modes with no change here). applyMode // itself sets the model's active mode, so we only compute the target and apply. 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) applyMode(g_session->view(), target, nullptr); persistViewState(); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } // Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if // the id is unregistered, so an absent mode fails safe. void doActivateMode(const std::string& modeId) { applyMode(g_session->view(), modeId, nullptr); persistViewState(); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } // Tag the selection's leaves into `modeId`, then reapply so the change is immediate. // tag() replaces any prior single-mode membership (a leaf lives in one mode; the // cross-mode case is show-both), matching the D1 contract. void doTag(const std::string& modeId) { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().tag(g, modeId); reapplyActiveMode(); persistViewState(); } // Untag the selection entirely (return each to the Arrange default). This is the // shared body behind both "Untag selected" and "Tag -> Arrange" (Arrange = the // absence of a tag), so the two actions are the same act by definition. void doUntag() { for (const std::string& g : selectedTrackGuids()) g_session->view().membership().untag(g); reapplyActiveMode(); persistViewState(); } // Toggle the per-track show-both pin for the selection. Read the CURRENT pin of each // track and flip it independently (a mixed selection converges toward "all on" then // "all off" only if uniform; per-track flip is the honest semantics of a toggle on a // multi-selection). show-both leaves are never parked (D1), so reapply reflects the // change immediately. void doShowBoth() { MembershipIndex& m = g_session->view().membership(); for (const std::string& g : selectedTrackGuids()) m.setShowBoth(g, !m.isShowBoth(g)); reapplyActiveMode(); persistViewState(); } // -- Item-level mode moves (D2 Wave 3-B) ----------------------------------- // // Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default), // then re-drive the minting + apply path so each moved item lands on its target mode's // managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag // decides which selected items to retag (manual-lane items are EXEMPT — never retagged, // never re-laned), upholding the managed-lanes-only invariant even under this explicit // user action. The whole structural act is wrapped in ONE Undo block with a descriptive // label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it). // // UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which // must NOT sit inside the Undo block, so we close the block first, then persist — the same // separation the track actions rely on (they persist outside applyMode's own block). void doMoveItems(const std::string& targetMode) { const std::vector selected = selectedRetagItems(); const std::vector ops = planItemRetag(selected, targetMode); if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty MembershipIndex& membership = g_session->view().membership(); Undo_BeginBlock2(nullptr); // Apply the pure decision's membership writes: tag into targetMode, or untag. 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 and assign each moved item to its mode's managed lane, // then reassert the active mode's lane visibility. Manual lanes stay untouched // (mintManagedLanes reports their items exempt and never mints over them). mintManagedLanes(g_session->view(), nullptr); reapplyActiveMode(); const std::string label = targetMode.empty() ? std::string("ReaSampler: untag selected items") : std::string("ReaSampler: move selected items -> ") + targetMode; Undo_EndBlock2(nullptr, label.c_str(), -1); persistViewState(); } } // namespace void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { g_session = session; // command_id -> gaccel for each. The single hookcommand that routes these lives // in main.cpp (one hook per extension); designViewHandleCommand services them. g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, "toggle Design View mode"); g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, "activate mode Arrange"); g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, "activate mode Design"); g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, "tag selected tracks -> Design"); g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, "tag selected tracks -> Arrange"); g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, "untag selected tracks"); g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, "show both for selected tracks"); // Item-level mode moves (D2 W3-B): the item analog of the track tag family. g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, "move selected items -> Design"); g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, "move selected items -> Arrange"); g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, "untag selected items"); } bool designViewHandleCommand(int command) { if (command == 0 || !g_session) return false; if (command == g_cmdToggleMode) { doToggleMode(); return true; } if (command == g_cmdActivateArrange) { doActivateMode(kArrangeModeId); return true; } if (command == g_cmdActivateDesign) { doActivateMode(kDesignModeId); return true; } if (command == g_cmdTagDesign) { doTag(kDesignModeId); return true; } // Tag -> Arrange and Untag are the same act (Arrange = the absence of a tag). if (command == g_cmdTagArrange) { doUntag(); return true; } if (command == g_cmdUntag) { doUntag(); return true; } if (command == g_cmdShowBoth) { doShowBoth(); return true; } // Item-level moves. Move -> Arrange and Untag items collapse to the same act (an // empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above. if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; } if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; } if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; } return false; // not ours — caller's hookcommand keeps looking } void designViewUnregisterActions(reaper_plugin_info_t* rec) { // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. // gaccel first, then the command_id string (reverse of registration order — the item // moves registered last, so they tear down first). // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor // returns the memoized pointer registered above), so the unregister matches exactly. rec->Register("-gaccel", (void*)&g_accelUntagItems); rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); rec->Register("-gaccel", (void*)&g_accelShowBoth); rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); rec->Register("-gaccel", (void*)&g_accelUntag); rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); rec->Register("-gaccel", (void*)&g_accelTagArrange); rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); rec->Register("-gaccel", (void*)&g_accelTagDesign); rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); rec->Register("-gaccel", (void*)&g_accelActivateDesign); rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); rec->Register("-gaccel", (void*)&g_accelActivateArrange); rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); rec->Register("-gaccel", (void*)&g_accelToggleMode); rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); g_session = nullptr; } // =========================================================================== // Multi-bank action family (Phase B3) // =========================================================================== // // Each action drives the B1 model on g_session->book() and persists via // g_session->saveToActiveProject() so the change travels with the .rpp — exactly as // the capture path persists a new Sample (main.cpp RunCapture). The book's rules // (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in bank_book; // these handlers only call the model and react to the boolean / TransferResult. // // REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index // return a reference INTO the book's internal vector, which a create/delete can // reallocate. No handler here caches a BankIndex& (or a Bank*) across a structural // mutation — each resolves ids to strings up front and re-resolves after any // create/delete. Move/copy pass ids (not references) straight to moveSample/copySample. namespace { // FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is // prepended at register via channelCommandId (as with the Design View family above) — // stable rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. // Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly // (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). constexpr const char* kIdBankCreate = "BANK_CREATE"; constexpr const char* kIdBankRename = "BANK_RENAME"; constexpr const char* kIdBankDelete = "BANK_DELETE"; constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; // Phase R (Reclaim), R2: the FOREVER-STABLE "Prune bank folder" id. Registered NOW so // in-DAW dry-run verification is possible; R2 behaviour is REPORT-ONLY (no deletion), // and R3 extends the confirm-and-delete step behind this SAME id — never a throwaway id. constexpr const char* kIdBankPruneFolder = "BANK_PRUNE_FOLDER"; int g_cmdBankCreate = 0; int g_cmdBankRename = 0; int g_cmdBankDelete = 0; int g_cmdBankEvacuate = 0; int g_cmdBankActivateNext = 0; int g_cmdBankActivatePool = 0; int g_cmdBankMoveSel = 0; int g_cmdBankCopySel = 0; int g_cmdBankRemoveSel = 0; int g_cmdBankPoolFull = 0; int g_cmdBankBanksFull = 0; int g_cmdBankPruneFolder = 0; gaccel_register_t g_accelBankCreate{}; gaccel_register_t g_accelBankRename{}; gaccel_register_t g_accelBankDelete{}; gaccel_register_t g_accelBankEvacuate{}; gaccel_register_t g_accelBankActivateNext{}; gaccel_register_t g_accelBankActivatePool{}; gaccel_register_t g_accelBankMoveSel{}; gaccel_register_t g_accelBankCopySel{}; gaccel_register_t g_accelBankRemoveSel{}; gaccel_register_t g_accelBankPoolFull{}; gaccel_register_t g_accelBankBanksFull{}; gaccel_register_t g_accelBankPruneFolder{}; // Persists the book after a bank mutation. Mirrors the CAPTURE path (main.cpp // RunCapture), NOT the Design-View path: a bank change is held in-session and written // to the active project's ext state so it travels with the .rpp. Deliberately no // Save-As prompt — saveToActiveProject no-ops on an unsaved project (the change stays // valid for the session and persists on the user's next save), exactly as capture // persists. This is an intentional divergence from persistViewState (above), which // DOES prompt Save-As on an unsaved project; do not "align" the two — a bank mutation // follows capture's quiet-persist idiom, a Design-View mutation follows the prompt idiom. // Returns whether a persist actually happened (false on an unsaved/no-active project), // so persistBankOp can skip its undo block when nothing was written. bool persistBook() { return g_session->saveToActiveProject(); } // Persists a completed bank index verb (create/rename/reorder/delete/evacuate/ // move/copy) as a SINGLE batched REAPER undo point (R-B) — one bank op = one Ctrl-Z. // // WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project // ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures // iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents // MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). // We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family // does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them // would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs // INSIDE the block so the post-mutation ext-state is the block's "after" image. // // NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a // rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, // so no dangling/empty undo point is ever opened for a rejected op. // // Prompts the user for a single line of text via REAPER's stock input dialog. // GetUserInputs(title, num_inputs=1, captions_csv, retvals_csv, sz) -> false on // cancel (SDK ~3808). `initial` pre-fills the field. Returns false (leaving `out` // untouched) on cancel or an empty entry. Self-contained bindable-action name entry; // B4's panel affordances supersede this with in-panel editing. // // COMMA GUARD: GetUserInputs splits the returned values on a separator that defaults // to ',', so a bank name containing a comma would be truncated at the comma. We // override the return separator to \x1f (ASCII unit separator, un-typeable in the // dialog) via the documented `separator=X` extra caption field (SDK ~3806), so any // printable name — commas included — round-trips whole. The captions_csv itself stays // comma-joined: the single field caption, then the `separator=` directive as a // trailing pseudo-caption (the directive redefines only the RETURN separator). bool promptText(const char* title, const char* caption, const std::string& initial, std::string& out) { std::vector buf(512, '\0'); // Pre-fill: GetUserInputs seeds the field from the retvals buffer's initial value. std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); const std::string captions = std::string(caption) + ",separator=\x1f"; if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), static_cast(buf.size()))) return false; // user cancelled std::string s(buf.data()); if (s.empty()) return false; // an empty name is not a valid bank name out = std::move(s); return true; } // Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model design: // ids are caller-supplied and stable; the model stays pure and mints none). Distinct // from a track GUID by origin only — both are canonical guidToString output. std::string mintBankId() { GUID g{}; genGuid(&g); char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) guidToString(&g, buf); return std::string(buf); } // Resolves a user-typed bank reference (a display name) to a bank id, scanning the // book's banks in ordinal order. Exact match on displayName; "Pool" resolves the pool. // Returns "" when no bank carries that name. Kept in the action layer (not the model) // — it is UI name-resolution, not a model rule. First-match is unambiguous BY // CONSTRUCTION: the model enforces unique display names (trimmed + case-insensitive), // so at most one bank can carry a given name — no duplicate can shadow another here. std::string bankIdByDisplayName(const std::string& name) { for (const Bank& b : g_session->book().banks()) if (b.displayName == name) return b.id; return {}; } // -- Action bodies --------------------------------------------------------- // Create a named bank: prompt for a display name, mint a stable GUID id, create it in // the model, persist. The new bank is NOT auto-activated (create and activate are // distinct acts — mirrors capture/placement separation). The model rejects a display // name that duplicates an existing bank's (trimmed + case-insensitive, incl. "Pool"); // the create then fails and the user is told the name is taken. void doBankCreate() { std::string name; if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = mintBankId(); if (!g_session->book().createBank(id, name)) { ShowConsoleMsg( ("ReaSampler: could not create bank \"" + name + "\" (a bank with that name already exists).\n") .c_str()); return; } persistBankOp("ReaSampler: create bank"); } // Rename a bank: prompt for which bank (by current display name) and the new name. // The pool is un-renamable (the model rejects it). Two prompts keep the bindable form // self-contained; B4's panel renames in place on a tab. void doBankRename() { std::string which; if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", which)) return; const std::string id = bankIdByDisplayName(which); if (id.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); return; } std::string newName; if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; if (!g_session->book().renameBank(id, newName)) { // renameBank rejects the pool (un-renamable) or a name already used by another // bank (unique display names, trimmed + case-insensitive). ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable, " "or another bank already uses that name).\n"); return; } persistBankOp("ReaSampler: rename bank"); } // Delete a named bank. Bindable safe-form of the confirm-on-non-empty guardrail: // prompt for the bank; if it holds members, a YESNO ShowMessageBox names evacuate as // the alternative before dropping them (a plain delete orphans those members' files // until prune — CONTEXT.md §delete). An empty bank deletes with no prompt. The richer // panel confirm (naming evacuate inline, with a one-click evacuate) arrives in B4. void doBankDelete() { std::string which; if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; const std::string id = bankIdByDisplayName(which); if (id.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); return; } // Pool early-out: the pool is un-deletable (the model rejects it). Catch it here, // BEFORE the non-empty confirm, so typing "Pool" never shows a misleading // "delete anyway?" prompt for an operation the model will refuse regardless. if (id == kPoolBankId) { ShowConsoleMsg("ReaSampler: the pool cannot be deleted.\n"); return; } // Read member count BEFORE deleting (the Bank* is invalidated by deleteBank; we do // not cache it — resolve size to an int up front). const Bank* b = g_session->book().bank(id); if (!b) return; // race-safe: id resolved above but re-check const std::size_t members = b->index.size(); if (members > 0) { const std::string msg = "\"" + which + "\" holds " + std::to_string(members) + (members == 1 ? " sample" : " samples") + ".\n\nDeleting drops them from every bank (their files are NOT deleted, " "but no bank will reference them until prune).\n\nTo keep the samples, " "cancel and Evacuate the bank to the pool first.\n\nDelete anyway?"; const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 4); if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) } if (!g_session->book().deleteBank(id)) { ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); return; } // S9: bump only when the deleted bank held samples — dropping them changes what a live // instance referencing one could play. Deleting an EMPTY bank is purely organizational. persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); } // Evacuate a named bank: move every member back to the pool (index-only, collapse by // hash), leaving the bank empty. The pool is un-evacuable (the model rejects it). The // intended "keep the samples" companion to delete. void doBankEvacuate() { std::string which; if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", which)) return; const std::string id = bankIdByDisplayName(which); if (id.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + which + "\".\n").c_str()); return; } if (!g_session->book().evacuate(id)) { ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " "destination, not a source).\n"); return; } // S9: evacuate moves members between banks (bank membership changes) -> bump. persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); } // Cycle the active bank forward in ordinal order (pool -> named -> ... -> pool), // via the pure nextBankId helper. Activating a bank changes the CAPTURE TARGET (the // next capture lands in the newly-active bank — B2's book().activeIndex() seam) and // never touches the timeline. Persist so the active id travels with the .rpp. void doBankActivateNext() { std::vector ids; ids.reserve(g_session->book().size()); for (const Bank& b : g_session->book().banks()) ids.push_back(b.id); const std::string target = nextBankId(ids, g_session->book().activeBankId()); if (target.empty()) return; // degenerate (no banks) — cannot happen (pool seeded) if (!g_session->book().setActiveBank(target)) return; persistBankOp("ReaSampler: activate bank"); } // Activate the pool directly (the common "back to the default target" jump). Bindable // direct-by-id form; a general activate-bank-by-name/menu is a B4 affordance. void doBankActivatePool() { if (!g_session->book().setActiveBank(kPoolBankId)) return; persistBankOp("ReaSampler: activate bank"); } // Move or copy the panel's selected samples into a named destination bank (prompted // by display name). The SOURCE is the bank the selection lives in — the focused // region's displayed bank (bankPanelSelectedSourceBankId), which under B4's vertical // split is NOT necessarily the active/capture-target bank (active ≠ shown). Both are // index-only (files never relocate); move removes the source entry, copy retains it; // both observe destination collapse-by-hash (bank_book). B4's "move to bank" menu // drives moveSample/copySample directly with a menu-chosen destination — this bindable // form is the same operation with a text-prompt destination. void doBankTransferSelected(bool copy) { const std::vector selected = bankPanelSelectedSampleIds(); if (selected.empty()) { ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to " "move/copy.\n"); return; } const char* verb = copy ? "copy" : "move"; const std::string title = std::string("ReaSampler: ") + verb + " selected samples"; std::string destName; if (!promptText(title.c_str(), "Destination bank:", "", destName)) return; const std::string destId = bankIdByDisplayName(destName); if (destId.empty()) { ShowConsoleMsg(("ReaSampler: no bank named \"" + destName + "\".\n").c_str()); return; } // Source = the bank the selection lives in (the focused region's displayed bank). // Pass ids by value — no BankIndex& is cached across the loop's mutations. const std::string srcId = bankPanelSelectedSourceBankId(); if (srcId == destId) { ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); return; } // Tally per-sample transfer outcomes so the no-op guardrail below can decide whether // the index actually mutated (R-B). The console summary m11 stripped is gone; the // counts remain because the verb-aware undo guardrail is driven by them. int ok = 0, collapsed = 0; for (const std::string& sampleId : selected) { const TransferResult r = copy ? g_session->book().copySample(sampleId, srcId, destId) : g_session->book().moveSample(sampleId, srcId, destId); switch (r) { case TransferResult::Moved: case TransferResult::Copied: ++ok; break; case TransferResult::Collapsed: ++collapsed; break; // RejectedSampleAbsent and unknown-bank / same-bank (pre-checked above) are // no-ops for the guardrail; nothing mutated for those ids. case TransferResult::RejectedSampleAbsent: case TransferResult::RejectedUnknownBank: case TransferResult::RejectedSameBank: break; } } // No-op guardrail — VERB-AWARE (a collapse means different things per verb): // * MOVE collapse: the source entry WAS removed (bank_book moveSample removes // unconditionally before the dest add collapses on hash), so the index DID // mutate — it counts toward opening an undo point. // * COPY collapse: the source is left intact AND the dest already held the hash, // so NOTHING changed — a true index no-op. It must NOT open an undo point. // Hence: copy counts only real gains (ok); move counts gains OR collapses. const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); if (mutated) { const std::string label = std::string("ReaSampler: ") + verb + " sample(s)"; // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an // instance may reference) -> bump so assigned instances refresh hands-free. persistBankOp(label.c_str(), /*bumpGeneration=*/true); } } // Remove the panel's selected samples from the SOURCE bank (the focused region's // displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only // and non-destructive to the file: a last-reference remove leaves the file on disk, // orphaned until Phase R prune (remove NEVER deletes bytes — the manifest is untouched). // // SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks // seam stays latent in the model; nothing here reaches for it. // // SILENT REMOVE: removes proceed without a confirm dialog. Recoverability is provided // by the batched REAPER undo (R-B) — one Ctrl-Z restores the index entry. Files are // never deleted by remove (orphaned-until-prune is unchanged). hashReferencedElsewhere // is a tested model API retained for Phase R prune; it has no shell caller here. void doBankRemoveSelected() { const std::vector selected = bankPanelSelectedSampleIds(); if (selected.empty()) { ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); return; } const std::string srcId = bankPanelSelectedSourceBankId(); BankBook& book = g_session->book(); if (book.bank(srcId) == nullptr) { ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); return; } // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached // across the loop's mutations. Count real drops so the no-op guardrail can skip the // undo point when nothing was removed (every id was already absent). int removed = 0; for (const std::string& sampleId : selected) { if (book.removeSample(sampleId, srcId, RemoveScope::ThisBank) == RemoveResult::Removed) ++removed; // RejectedSampleAbsent / RejectedUnknownBank are no-ops for the guardrail. // (Unknown bank cannot occur — srcId was resolved to a live bank above.) } // No-op guardrail (R-B): open an undo point only if the index actually mutated. // S9: a remove drops a sample from a bank (an instance referencing it must refresh — it // will resolve to silence, per the stale-id policy) -> bump. if (removed > 0) persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); } // Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE // file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only), // then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest // (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then // on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the // pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback). // Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing. // // The full (untruncated) orphan set is captured here for the delete; the dry-run's // truncated list is only the confirm's readout. No ext-state is written and no undo point // is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project // state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files. void doBankPruneFolder() { const PruneReport report = g_session->pruneDryRun(); if (report.count == 0) { ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n"); return; } // The EXACT set the delete will target — full, untruncated, so what the confirm // summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the // confirm so the confirm and the delete reason about the same enumeration. const std::vector orphanSet = g_session->pruneOrphanSet(); // Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped // list (the same clip the R2 readout used), with a "N more not shown" tail when clipped. std::string msg = "ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) + " orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n" "These files are no longer referenced by any bank and were created by ReaSampler.\n" "They will be moved to the Recycle Bin on Windows (recoverable), or deleted on " "other platforms.\n\n"; for (const std::string& rel : report.orphans) msg += " " + rel + "\n"; if (report.truncated) { msg += " ... (" + std::to_string(report.count - report.orphans.size()) + " more not shown)\n"; } msg += "\nReclaim these files now?"; const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4); if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544) ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n"); return; } // Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped). const PruneDeletionResult del = g_session->pruneReclaim(orphanSet); std::string done = "ReaSampler prune: reclaimed " + std::to_string(del.reclaimedCount) + " file(s), " + std::to_string(del.reclaimedBytes) + " bytes" + (del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + "."; if (del.skippedCount > 0) { done += " " + std::to_string(del.skippedCount) + " file(s) skipped (locked, or changed since the report)."; } done += "\n"; ShowConsoleMsg(done.c_str()); } } // namespace // Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — // one bank op = one Ctrl-Z. Declared in actions.h so bank_panel.cpp can call it // without duplicating the undo logic. // // WHY THIS WRAPS AND persistBook() DOES NOT: a bank verb mutates ONLY our project // ext-state (SetProjExtState under "reasampler"), which REAPER's undo system captures // iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK documents // MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h ~1544, ~1199). // We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the item-move family // does): a bank verb touches no tracks, FX, items, or envelopes, so snapshotting them // would be both heavier and semantically wrong. persistBook() (= SetProjExtState) runs // INSIDE the block so the post-mutation ext-state is the block's "after" image. // // NO-OP GUARDRAIL: callers invoke this ONLY after the model mutation succeeded — a // rejected op (duplicate name, un-deletable pool, etc.) returns before reaching here, // so no dangling/empty undo point is ever opened for a rejected op. // // UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project persistBook() no-ops // (nothing is written to ext state). We must still CLOSE the block we opened, but with // an EMPTY label and a zero flag so REAPER DISCARDS the point instead of recording a // no-effect undo entry — mirroring view.cpp's empty-plan close. The in-session model // change stands and persists on the user's next save; it just earns no undo point until // there is a project to persist into (undo of an unsaved bank op has nothing to roll // back to anyway). The Begin/End must still be balanced, hence the close-either-way. void persistBankOp(const char* label, bool bumpGeneration) { Undo_BeginBlock2(nullptr); // S9: bump the bank-generation counter INSIDE the block, before persistBook(), so the // fresh generation rides the same ext-state write the persist makes (persistBook() -> // saveToActiveProject() stamps bankGeneration()). Bumped only for content-changing verbs // (the caller decides); a pure-organizational verb passes false and leaves the counter be, // so a rename/activate does not needlessly refresh live instances. if (bumpGeneration) g_session->bumpBankGeneration(); const bool persisted = persistBook(); if (persisted) Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); else Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point } void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { g_session = session; // shared with the Design View family; same live session g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, "create bank"); g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, "rename bank"); g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, "delete bank"); g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, "evacuate bank to pool"); g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, "activate next bank (cycle)"); g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, "activate pool"); g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, "move selected samples to bank"); g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, "copy selected samples to bank"); g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, "remove selected samples"); g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, "toggle pool full-height"); g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, "toggle banks full-height"); // Phase R, R2: the "Prune bank folder" action (report-only in this wave; R3 extends // the confirm-and-delete step behind this SAME forever-stable id). g_cmdBankPruneFolder = registerAction(rec, kIdBankPruneFolder, g_accelBankPruneFolder, "prune bank folder"); } bool bankHandleCommand(int command) { if (command == 0 || !g_session) return false; if (command == g_cmdBankCreate) { doBankCreate(); return true; } if (command == g_cmdBankRename) { doBankRename(); return true; } if (command == g_cmdBankDelete) { doBankDelete(); return true; } if (command == g_cmdBankEvacuate) { doBankEvacuate(); return true; } if (command == g_cmdBankActivateNext) { doBankActivateNext(); return true; } if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } if (command == g_cmdBankPruneFolder) { doBankPruneFolder(); return true; } return false; // not ours — caller's hookcommand keeps looking } int bankPruneCommandId() { return g_cmdBankPruneFolder; } void bankUnregisterActions(reaper_plugin_info_t* rec) { // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). rec->Register("-gaccel", (void*)&g_accelBankPruneFolder); rec->Register("-command_id", (void*)channelIdFor(kIdBankPruneFolder)); rec->Register("-gaccel", (void*)&g_accelBankBanksFull); rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); rec->Register("-gaccel", (void*)&g_accelBankPoolFull); rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); rec->Register("-gaccel", (void*)&g_accelBankCopySel); rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); rec->Register("-gaccel", (void*)&g_accelBankMoveSel); rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); rec->Register("-gaccel", (void*)&g_accelBankActivatePool); rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); rec->Register("-gaccel", (void*)&g_accelBankActivateNext); rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); rec->Register("-gaccel", (void*)&g_accelBankEvacuate); rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); rec->Register("-gaccel", (void*)&g_accelBankDelete); rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); rec->Register("-gaccel", (void*)&g_accelBankRename); rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); rec->Register("-gaccel", (void*)&g_accelBankCreate); rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); // g_session is shared with the Design View family; designViewUnregisterActions // also nulls it. Nulling twice is harmless. Leave it to whichever runs last. g_session = nullptr; } } // namespace reasampler