From 430e11762003345369e532494299530648e82320 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 12:56:02 -0400 Subject: [PATCH 1/2] Q-W4: split actions.cpp into design_view_actions/bank_actions/prune_action + shared action_registry; bank verbs deduped into promptless bankOp* inner verbs in panel_bank_ops (one mutation home, two UX skins); command-id strings byte-identical; actions.h shim carrier retired --- CMakeLists.txt | 7 +- src/actions.cpp | 1019 -------------------- src/actions.h | 93 -- src/app/main.cpp | 3 +- src/core/model/provenance.h | 2 +- src/core/version/app_version.h | 2 +- src/ingest.cpp | 5 +- src/shell/actions/action_registry.cpp | 49 + src/shell/actions/action_registry.h | 29 + src/shell/actions/bank_actions.cpp | 366 +++++++ src/shell/actions/bank_actions.h | 45 + src/shell/actions/design_view_actions.cpp | 383 ++++++++ src/shell/actions/design_view_actions.h | 38 + src/shell/actions/prune_action.cpp | 102 ++ src/shell/actions/prune_action.h | 22 + src/shell/capture/capture_orchestrator.cpp | 2 +- src/shell/capture/track_guid.h | 2 +- src/shell/panel/panel_bank_ops.cpp | 322 +++++-- src/shell/panel/panel_bank_ops.h | 86 +- src/shell/panel/panel_drag.cpp | 2 +- src/shell/panel/panel_input.cpp | 2 +- src/shell/panel/panel_layout.cpp | 2 +- src/shell/panel/panel_layout.h | 2 +- src/shell/panel/panel_state.h | 9 +- 24 files changed, 1353 insertions(+), 1241 deletions(-) delete mode 100644 src/actions.cpp delete mode 100644 src/actions.h create mode 100644 src/shell/actions/action_registry.cpp create mode 100644 src/shell/actions/action_registry.h create mode 100644 src/shell/actions/bank_actions.cpp create mode 100644 src/shell/actions/bank_actions.h create mode 100644 src/shell/actions/design_view_actions.cpp create mode 100644 src/shell/actions/design_view_actions.h create mode 100644 src/shell/actions/prune_action.cpp create mode 100644 src/shell/actions/prune_action.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f643210..c491bb3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -365,7 +365,7 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge # FX-chain identity fold, and the pure parent-detection decision (resample-from- # sample by resolved file path). Split out so the encoding + decision logic are # unit-tested outside the DAW; the FX-chain query, capture re-run, and action -# registration stay in the shell (main.cpp / actions.cpp). No dependency on +# registration stay in the shell (main.cpp / shell/actions/). No dependency on # bank_model — it takes plain strings/values at its boundary. # --------------------------------------------------------------------------- add_library(provenance STATIC src/core/model/provenance.cpp) @@ -1123,7 +1123,10 @@ add_library(reaper_reasampler MODULE src/core/view/guid_diff.cpp src/core/view/lane_keys.cpp src/shell/capture/item_read.cpp - src/actions.cpp + src/shell/actions/action_registry.cpp + src/shell/actions/design_view_actions.cpp + src/shell/actions/bank_actions.cpp + src/shell/actions/prune_action.cpp src/ingest.cpp src/core/model/bank_book.cpp src/core/model/owned_manifest.cpp diff --git a/src/actions.cpp b/src/actions.cpp deleted file mode 100644 index 488d6a0..0000000 --- a/src/actions.cpp +++ /dev/null @@ -1,1019 +0,0 @@ -#include "core/namespaces.h" -// 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 "core/version/app_version.h" // channelCommandId / channelActionName — one channel-identity point - -#include "core/model/bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) -#include "shell/panel/panel_bank_ops.h" // selection seam (B3/B4) -#include "shell/panel/panel_layout.h" // full-height toggles (B3) -#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint -#include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) -#include "core/view/lane_keys.h" // isOnManualLane — the single managed/manual predicate -#include "persist.h" // ReaSamplerSession (owns book() + view() model) -#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key -#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) -#include "core/view/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 BankModel& (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 BankModel& 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 BankModel& 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(); - - // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the - // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather - // than proceed with degraded protection. Distinct from "no orphans": the user must - // know the prune refused to run and why. - if (report.abortedUnreadableUsage) { - std::string msg = - "ReaSampler prune: ABORTED -- one or more instance usage records could not " - "be read or decoded. Nothing was deleted.\n" - "If the owning instance is still loaded it will republish its record on the " - "next poll tick, clearing the abort. If the instance no longer exists (the " - "key is an orphaned corrupt record), clear it manually via ReaScript:\n" - " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" - "Offending key(s):\n"; - for (const std::string& key : report.offendingUsageKeys) { - msg += " " + key + "\n"; - } - ShowConsoleMsg(msg.c_str()); - return; - } - - 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 diff --git a/src/actions.h b/src/actions.h deleted file mode 100644 index 936b381..0000000 --- a/src/actions.h +++ /dev/null @@ -1,93 +0,0 @@ -#pragma once -#include "core/namespaces.h" -// actions — the Design View action family (Phase D4). Registers the bindable -// actions that drive the mode workflow and wires them end-to-end: toggle/activate -// a mode, tag/untag/show-both the current track selection. Each action mutates the -// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies -// the active mode through the view shell (D2) so the change takes effect immediately. -// -// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This -// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. -// -// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned -// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership -// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that -// drives persist.poll()); this module only registers and services the actions. - -// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct -// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real -// definition. Declared before the namespace so it is the global type, not a -// namespace-local shadow. -struct reaper_plugin_info_t; - -namespace reasampler { - -class ReaSamplerSession; - -// Registers the Design View action family against `rec` (command_id + gaccel + -// hookcommand-routing is owned by the caller's single hookcommand). `session` is the -// live session the actions mutate; it must outlive the registration. Idempotent is -// NOT promised — call exactly once at load, mirror-unregister once at unload. -void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command. Returns true iff `command` is one of this module's -// action ids (and it was handled); false otherwise so the caller's hookcommand keeps -// looking (per the contract: claim only our own ids). Safe to call for any command. -bool designViewHandleCommand(int command); - -// Mirror-unregisters everything designViewRegisterActions registered, with the -// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. -void designViewUnregisterActions(reaper_plugin_info_t* rec); - -// --- Multi-bank action family (Phase B3) ----------------------------------- -// The bindable action set that drives the multi-bank workflow: create / rename / -// delete / evacuate a bank, activate a bank (direct pool/design-free + cycle), move / -// copy the panel's selected samples into a bank, and the two vertical-split -// full-height toggles. Every mutating action drives the B1 model on -// g_session.book() and persists via g_session.saveToActiveProject() so the change -// travels with the .rpp; the toggles flip the B4-rendered layout bit on the panel. -// -// Same registration/routing/unload contract as the Design View family above and the -// same shared g_session. Kept a distinct trio (not folded into the Design View one) -// because the two families are orthogonal pillars — but they share the single -// hookcommand main.cpp owns; each family's Handle claims only its own ids. - -// Registers the multi-bank family against `rec`. `session` is the live session (must -// outlive registration). Call exactly once at load. Shares g_session with the Design -// View family — pass the SAME session pointer. -void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); - -// Services one fired command for the multi-bank family. True iff it was one of this -// family's ids (and handled); false otherwise so the caller's hookcommand keeps -// looking. Safe for any command. -bool bankHandleCommand(int command); - -// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on -// rec==nullptr (before g_session is torn down). -void bankUnregisterActions(reaper_plugin_info_t* rec); - -// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before -// registration. The bank_panel prune button fires the action THROUGH this id via -// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session -// directly) so the panel affordance and the bindable action share one guarded code path. -int bankPruneCommandId(); - -// Persists a completed bank-index verb as a single REAPER undo point (R-B). -// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG -// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops -// and the block is closed with an empty label + zero flag (REAPER discards it). Callers -// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate -// name, un-deletable pool, etc.) must return before reaching here so no empty undo -// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook(). -// -// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live -// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left, -// arrived, or dropped out of a bank an instance may reference). Leave it false (the default) -// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no -// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when -// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the -// same ext-state write and undo captures the pre/post generation with the rest of the blob. -void persistBankOp(const char* label, bool bumpGeneration = false); - -} // namespace reasampler diff --git a/src/app/main.cpp b/src/app/main.cpp index 7200eaa..85f08f4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -28,11 +28,12 @@ #include #include -#include "actions.h" #include "core/capture/render_settings.h" // captureActionTable #include "core/version/app_version.h" // channelCommandId / channelActionName / appVersion #include "ingest.h" #include "persist.h" +#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) +#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) #include "shell/capture/capture_batch.h" // batch + recapture action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver diff --git a/src/core/model/provenance.h b/src/core/model/provenance.h index cd6255d..14c4cfb 100644 --- a/src/core/model/provenance.h +++ b/src/core/model/provenance.h @@ -2,7 +2,7 @@ // provenance — the REAPER-free core behind Milestone 10 (re-capture from source). // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO -// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp) +// vendor/ includes. Standard library only. The shell (main.cpp / the action families) // gathers the raw inputs from REAPER — the source item media-file names, the // source track FX-chain identity (names / GUIDs / enabled flags), the exact // capture range, scope, tail — and hands plain strings/values here. This module diff --git a/src/core/version/app_version.h b/src/core/version/app_version.h index 603dcf1..8ef85bf 100644 --- a/src/core/version/app_version.h +++ b/src/core/version/app_version.h @@ -140,7 +140,7 @@ const std::string& vstPluginName(); // --- Channel-qualified action id / name builders ------------------------------------ // -// The two composition helpers every action-registering shell (main.cpp, actions.cpp) +// The two composition helpers every action-registering shell (main.cpp, action_registry) // funnels through, so command ids and Actions-list names are qualified IDENTICALLY on // every channel from ONE definition — no shell re-implements the concatenation. // diff --git a/src/ingest.cpp b/src/ingest.cpp index bfc6d80..8bf93da 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -16,7 +16,6 @@ #include #include -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B path) #include "core/version/app_version.h" // channelCommandId / channelActionName #include "core/wire/assignment_request.h" // pure (bankId, sampleId, generation) encode #include "core/model/bank_book.h" // BankBook, Bank, activeBankId / activeIndex @@ -435,7 +434,7 @@ void doImportFromMediaExplorer() { // The generation is bumped only when something NEW landed (a dedup collapse mutated nothing, // so it needs neither a bump nor a persist to resolve — the sample is already in ext-state). // If saveToActiveProject() no-ops (unsaved project), close with an empty label + zero flag so - // REAPER discards the undo entry (the house pattern from actions.cpp). importFileIntoActiveBank + // REAPER discards the undo entry (the house pattern from persistBankOp). importFileIntoActiveBank // already refuses on an unsaved project, so in practice the persist here succeeds. Undo_BeginBlock2(nullptr); bool persisted = true; // true when nothing needed persisting (dedup) — governs the label path @@ -508,7 +507,7 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { // One undo point for the whole drop, opened only if a NEW index entry was created (a // drop that only re-hit existing content mutated nothing on the index). // If saveToActiveProject() no-ops (unsaved project), we close with an empty label + zero - // flag so REAPER discards the undo entry (house pattern from actions.cpp). + // flag so REAPER discards the undo entry (house pattern from persistBankOp). if (importedNew > 0) { Undo_BeginBlock2(nullptr); // S9: one coalesced generation bump for the whole drop (>=1 new sample landed) so diff --git a/src/shell/actions/action_registry.cpp b/src/shell/actions/action_registry.cpp new file mode 100644 index 0000000..1b7c1ec --- /dev/null +++ b/src/shell/actions/action_registry.cpp @@ -0,0 +1,49 @@ +// action_registry.cpp — shared registration plumbing (Q-W4 split of actions.cpp). +// See action_registry.h. Needs no REAPER API pointers: rec->Register is a member +// call on the dispatch struct REAPER hands the entry point. + +#include "shell/actions/action_registry.h" + +#include +#include + +#include "core/version/app_version.h" // channelCommandId / channelActionName + +namespace reasampler { + +namespace { + +using version::channelActionName; +using version::channelCommandId; + +// 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; + +} // namespace + +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(); +} + +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; +} + +} // namespace reasampler diff --git a/src/shell/actions/action_registry.h b/src/shell/actions/action_registry.h new file mode 100644 index 0000000..fd7f4ce --- /dev/null +++ b/src/shell/actions/action_registry.h @@ -0,0 +1,29 @@ +#pragma once +// action_registry — shared registration plumbing for the bindable action families +// (Q-W4 split of actions.cpp). Owns the durable interned-string store both the +// Design View and multi-bank families register through, so a composed command id +// keeps ONE stable pointer from register to the mirror-unregister, and the +// register-a-command_id-then-gaccel sequence has one implementation. +// +// Includes reaper_plugin.h (gaccel_register_t / reaper_plugin_info_t full defs); +// only the action-family TUs include this header. Q-W6's registration table +// subsumes this helper when the hand-written blocks become data. + +#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t + +namespace reasampler { + +// Returns the channel-qualified command id for `suffix`, interning it once for the +// process lifetime. Called by BOTH registerAction and each family's unregister path, +// so a '-command_id' presents the IDENTICAL string pointer registered earlier. +const char* channelIdFor(const char* suffix); + +// 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 — REAPER holds +// the desc pointer, and the id must survive to the mirror-unregister. The gaccel +// storage itself is caller-owned (file-scope in the family TU). +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase); + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp new file mode 100644 index 0000000..67ddf94 --- /dev/null +++ b/src/shell/actions/bank_actions.cpp @@ -0,0 +1,366 @@ +// bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.cpp). See bank_actions.h. +// +// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptText), +// name resolution, and console feedback — over the promptless bankOp* inner verbs +// homed in panel_bank_ops (model op + persistBankOp, one bank op = one Ctrl-Z). The +// book's rules (pool privileges, collapse-by-hash, active-fallback-to-pool) all live +// in bank_book; these handlers only drive the verbs and react to the boolean. +// +// 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 BankModel& (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 the verbs. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers (CLAUDE.md §contract). +// The action ids are minted from FOREVER-STABLE strings; user keybindings key off +// them, so they must never change after ship. + +#include "shell/actions/bank_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) +#include "shell/actions/prune_action.h" // doBankPruneFolder — the guarded prune body + +#include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) +#include "persist.h" // ReaSamplerSession (owns book()) +#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptText + selection seam +#include "shell/panel/panel_layout.h" // full-height toggles (B3) + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +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) — 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"; + +// The live session the actions read (name resolution, member counts, prune). The +// mutations themselves run through the bankOp* verbs, which resolve the same session +// via the panel seam. Set once by bankRegisterActions; not owned here. +ReaSamplerSession* g_session = nullptr; + +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 storage must outlive registration — REAPER holds each pointer until we +// mirror-unregister it. One per action. +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{}; + +// 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 (thin UX skins over the bankOp* verbs) ------------------- + +// Create a named bank: prompt for a display name; the verb mints a stable GUID id, +// creates it in the model, persists. The new bank is NOT auto-activated (create and +// activate are distinct acts — mirrors capture/placement separation). The model +// rejects a duplicate display name (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; + if (bankOpCreate(name).empty()) { + ShowConsoleMsg( + ("ReaSampler: could not create bank \"" + name + + "\" (a bank with that name already exists).\n") + .c_str()); + } +} + +// 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; the 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 (!bankOpRename(id, newName)) { + // The verb 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"); + } +} + +// 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) lives in the panel. +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 the delete; 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) + } + // 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. + if (!bankOpDelete(id, /*bumpGeneration=*/members > 0)) { + ShowConsoleMsg("ReaSampler: cannot delete that bank (the pool is un-deletable).\n"); + } +} + +// 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 verb 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 (!bankOpEvacuate(id)) { + ShowConsoleMsg("ReaSampler: cannot evacuate that bank (the pool is the " + "destination, not a source).\n"); + } +} + +// 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. The verb persists 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) + bankOpActivate(target); +} + +// 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 panel affordance. +void doBankActivatePool() { + bankOpActivate(kPoolBankId); +} + +// 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); the verb owns the verb-aware no-op guardrail and +// destination collapse-by-hash. The panel's "move to bank" menu drives the same verb +// 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). + const std::string srcId = bankPanelSelectedSourceBankId(); + if (srcId == destId) { + ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n"); + return; + } + bankOpTransfer(selected, srcId, destId, copy); +} + +// Remove the panel's selected samples from the SOURCE bank (the focused region's +// displayed bank — same source as move/copy). Index-only and non-destructive to the +// file (orphaned until Phase R prune); silent, with the batched undo as recovery — +// see bankOpRemove for the full contract. +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(); + if (g_session->book().bank(srcId) == nullptr) { + ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); + return; + } + bankOpRemove(selected, srcId); +} + +} // namespace + +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { + g_session = session; // same live session as the Design View family + + 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(*g_session); 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 = nullptr; +} + +} // namespace reasampler diff --git a/src/shell/actions/bank_actions.h b/src/shell/actions/bank_actions.h new file mode 100644 index 0000000..f8221e3 --- /dev/null +++ b/src/shell/actions/bank_actions.h @@ -0,0 +1,45 @@ +#pragma once +// bank_actions — the multi-bank bindable action family (Phase B3; Q-W4 split of +// actions.h). The bindable action set that drives the multi-bank workflow: create / +// rename / delete / evacuate a bank, activate a bank (direct pool + cycle), move / +// copy / remove the panel's selected samples, the two vertical-split full-height +// toggles, and the Phase R prune action's registration + dispatch (its guarded body +// lives in prune_action). Q-W4 dedupe: every mutating handler here is a THIN UX skin +// (text prompts + console messages) over the promptless bankOp* verbs homed in +// panel_bank_ops — one implementation home for each mutation, two UX skins (this +// family prompts for which bank; the panel acts on a clicked tab). +// +// Same registration/routing/unload contract as the Design View family +// (design_view_actions); both share main.cpp's single hookcommand, and each family's +// Handle claims only its own ids. This header is SDK-free. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef) so this +// header stays SDK-free; the .cpp includes the real definition. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the multi-bank family against `rec`. `session` is the live session (must +// outlive registration). Call exactly once at load — pass the SAME session pointer +// the Design View family receives. +void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command for the multi-bank family. True iff it was one of this +// family's ids (and handled); false otherwise so the caller's hookcommand keeps +// looking. Safe for any command. +bool bankHandleCommand(int command); + +// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on +// rec==nullptr (before the session is torn down). +void bankUnregisterActions(reaper_plugin_info_t* rec); + +// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 +// before registration. The bank_panel prune button fires the action THROUGH this id +// via Main_OnCommand (fork R-E: the button dispatches the command, it does not call +// the session directly) so the panel affordance and the bindable action share one +// guarded code path. +int bankPruneCommandId(); + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp new file mode 100644 index 0000000..11a66b8 --- /dev/null +++ b/src/shell/actions/design_view_actions.cpp @@ -0,0 +1,383 @@ +// design_view_actions.cpp — the Design View action family (Phase D4; Q-W4 split of +// actions.cpp). See design_view_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 "shell/actions/design_view_actions.h" + +#include +#include + +#include "shell/actions/action_registry.h" // channelIdFor / registerAction (shared plumbing) + +#include "core/view/lane_keys.h" // view::isOnManualLane — the single managed/manual predicate +#include "core/view/view_mode_model.h" +#include "persist.h" // ReaSamplerSession (owns view() model) +#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/view.h" // applyMode + mintManagedLanes (D2 shell) + +#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_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using view::isOnManualLane; + +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{}; + +// 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; +} + +} // namespace reasampler diff --git a/src/shell/actions/design_view_actions.h b/src/shell/actions/design_view_actions.h new file mode 100644 index 0000000..4006516 --- /dev/null +++ b/src/shell/actions/design_view_actions.h @@ -0,0 +1,38 @@ +#pragma once +// design_view_actions — the Design View action family (Phase D4; Q-W4 split of +// actions.h). Registers the bindable actions that drive the mode workflow and wires +// them end-to-end: toggle/activate a mode, tag/untag/show-both the current track +// selection, and the item-level mode moves (D2 W3-B). Each action mutates the +// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies +// the active mode through the view shell (D2) so the change takes effect immediately. +// +// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This +// header is SDK-free; main.cpp calls register/handle/unregister and nothing else. + +// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct +// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real +// definition. Declared before the namespace so it is the global type, not a +// namespace-local shadow. +struct reaper_plugin_info_t; + +namespace reasampler { + +class ReaSamplerSession; + +// Registers the Design View action family against `rec` (command_id + gaccel + +// hookcommand-routing is owned by the caller's single hookcommand). `session` is the +// live session the actions mutate; it must outlive the registration. Idempotent is +// NOT promised — call exactly once at load, mirror-unregister once at unload. +void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session); + +// Services one fired command. Returns true iff `command` is one of this module's +// action ids (and it was handled); false otherwise so the caller's hookcommand keeps +// looking (per the contract: claim only our own ids). Safe to call for any command. +bool designViewHandleCommand(int command); + +// Mirror-unregisters everything designViewRegisterActions registered, with the +// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr. +void designViewUnregisterActions(reaper_plugin_info_t* rec); + +} // namespace reasampler diff --git a/src/shell/actions/prune_action.cpp b/src/shell/actions/prune_action.cpp new file mode 100644 index 0000000..2c73851 --- /dev/null +++ b/src/shell/actions/prune_action.cpp @@ -0,0 +1,102 @@ +// prune_action.cpp — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). See prune_action.h for the contract this TU preserves. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). + +#include "shell/actions/prune_action.h" + +#include +#include + +#include "persist.h" // ReaSamplerSession — pruneDryRun / pruneOrphanSet / pruneReclaim + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_ShowMessageBox +#include "reaper_plugin_functions.h" + +namespace reasampler { + +// 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(ReaSamplerSession& session) { + const PruneReport report = session.pruneDryRun(); + + // pS-usage FAIL-SAFE: a present instance-usage record could not be read — the + // protected set is unknowable, so the prune HALTS outright (deletes nothing) rather + // than proceed with degraded protection. Distinct from "no orphans": the user must + // know the prune refused to run and why. + if (report.abortedUnreadableUsage) { + std::string msg = + "ReaSampler prune: ABORTED -- one or more instance usage records could not " + "be read or decoded. Nothing was deleted.\n" + "If the owning instance is still loaded it will republish its record on the " + "next poll tick, clearing the abort. If the instance no longer exists (the " + "key is an orphaned corrupt record), clear it manually via ReaScript:\n" + " reaper.SetProjExtState(0, \"reasampler\", \"\", \"\")\n" + "Offending key(s):\n"; + for (const std::string& key : report.offendingUsageKeys) { + msg += " " + key + "\n"; + } + ShowConsoleMsg(msg.c_str()); + return; + } + + 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 = 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 = 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 reasampler diff --git a/src/shell/actions/prune_action.h b/src/shell/actions/prune_action.h new file mode 100644 index 0000000..fed17df --- /dev/null +++ b/src/shell/actions/prune_action.h @@ -0,0 +1,22 @@ +#pragma once +// prune_action — the "Prune bank folder" action body (Phase R3; Q-W4 split of +// actions.cpp). This is the SOLE file-deletion action in ReaSampler, isolated in its +// own TU so the deletion authority is one obvious module on the actions side (its +// persist-side counterpart concentrates into prune_fs in Q-W5). Registration and +// hookcommand routing for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay with the +// bank family (bank_actions) — one registration flow, one guarded body here. +// +// Contract (preserve exactly): dry-run first; abort outright on unreadable usage +// records (pS-usage fail-safe); confirm-with-manifest before any deletion; opens NO +// undo point and writes NO ext state (file deletion is not REAPER-undoable). Routes +// to persist's public session API only (pruneDryRun / pruneOrphanSet / pruneReclaim). + +namespace reasampler { + +class ReaSamplerSession; + +// Runs the guarded prune flow against `session`. Called by the bank family's +// hookcommand handler when the BANK_PRUNE_FOLDER action fires. +void doBankPruneFolder(ReaSamplerSession& session); + +} // namespace reasampler diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index e106857..6841ccf 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -331,7 +331,7 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def) // An undo that removes the captured sample also clears the assign_request that named it, // preventing a stale request from pointing at a removed sample. The block uses the house // pattern (UNDO_STATE_MISCCFG, discarded on an unsaved project with empty label + zero -// flag) matching the bank-op family in actions.cpp. +// flag) matching the bank-op family (persistBankOp, panel_bank_ops). void RunCaptureItemAssign(ReaSamplerSession& session) { // Reuse the Item-scope def from the capture table (index 0) — same range logic, same diff --git a/src/shell/capture/track_guid.h b/src/shell/capture/track_guid.h index ec28cae..f1fa3d7 100644 --- a/src/shell/capture/track_guid.h +++ b/src/shell/capture/track_guid.h @@ -2,7 +2,7 @@ #include "core/namespaces.h" // track_guid — the ONE place a MediaTrack* is formatted into the canonical GUID // string used as a membership-index key. Both the Design View shell (view.cpp) and -// the actions layer (actions.cpp) key membership on this exact string, so the key +// the actions layer (design_view_actions.cpp) key membership on this exact string, so the key // contract lives in a single helper rather than being re-derived (and drifting) at // two call sites (the cross-module key contract flagged in D2 review). // diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index 621eb04..d3d8b3b 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -1,13 +1,15 @@ // panel_bank_ops.cpp — the bank-CRUD + menus seam of the docked bank panel (Q-W2 -// split of bank_panel.cpp; Phase B4/B5). The SINGLE home of the panel-side bank verbs -// (create / rename / delete / evacuate / activate / move / copy / remove) — the owner -// Q-W4 dedupes actions.cpp against — plus the book/bank accessors, the popup menus -// that drive them, and the selection-id / OS-drag path resolvers. +// split of bank_panel.cpp; Phase B4/B5). Since Q-W4 this TU is the ONE implementation +// home of the bank verbs (create / rename / delete / evacuate / activate / move / +// copy / remove): the promptless bankOp* inner verbs (model op + persistBankOp only) +// serve BOTH thin UX skins — the panel's menu handlers here and the bindable +// bank_actions family — plus the book/bank accessors, the popup menus that drive +// them, and the selection-id / OS-drag path resolvers. // -// Each op mutates g_session.book() then persists via persistBankOp() (one bank op = -// one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank BOOK — -// that is the whole point of B4 — but only the index/model + ext-state, never the -// arrange, never a sample file on disk (bank ops are index-only; files stay put — +// Each verb mutates the session's book() then persists via persistBankOp() (one bank +// op = one Ctrl-Z; a true index no-op opens NO undo point). It DOES mutate the bank +// BOOK — that is the whole point of B4 — but only the index/model + ext-state, never +// the arrange, never a sample file on disk (bank ops are index-only; files stay put — // CONTEXT.md §Multi-bank). REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL // mutation any Bank*/BankModel& is invalid — resolve fresh, pass ids. // @@ -23,7 +25,6 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_bank_ops.h" -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "persist.h" // ReaSamplerSession — the live session the ops mutate #define REAPERAPI_MINIMAL @@ -33,6 +34,8 @@ #define REAPERAPI_WANT_Main_OnCommand #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::panel { @@ -79,55 +82,28 @@ std::vector namedBanks() { return out; } -// --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- +// --- Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs) ------ // -// Each op mutates g_session.book() then persists via persistBankOp(). After a -// STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we -// resolve fresh, pass ids, and let the next refreshFingerprint repaint. On an -// unsaved project the empty-close discard in persistBankOp ensures no stale state +// Q-W4: each handler here owns only the panel's UX (prompts / confirms / message +// boxes / panel-state nudges / repaint); the model op + persist is the shared +// bankOp* inner verb (defined in the public section below). After a STRUCTURAL +// mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we resolve +// fresh, pass ids, and let the next refreshFingerprint repaint. On an unsaved +// project the empty-close discard in persistBankOp ensures no stale state // survives (matches the capture/B3 quiet-persist idiom). -namespace { - -// REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { - std::vector buf(512, '\0'); - 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; - std::string s(buf.data()); - if (s.empty()) return false; - out = std::move(s); - return true; -} - -// Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). -std::string mintBankId() { - GUID g{}; - genGuid(&g); - char buf[64] = {0}; - guidToString(&g, buf); - return std::string(buf); -} - -} // namespace - void doCreateBank() { if (!book()) return; std::string name; if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; - const std::string id = mintBankId(); - if (!book()->createBank(id, name)) { + const std::string id = bankOpCreate(name); + if (id.empty()) { ShowMessageBox("A bank with that name already exists.", "ReaSampler: create bank", 0); return; } g_panel.shownBankId = id; // show the freshly-created bank g_panel.focusedRegion = Region::Banks; - persistBankOp("ReaSampler: create bank"); invalidatePanel(); } @@ -140,12 +116,11 @@ void doRenameBank(const std::string& bankId) { const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; - if (!book()->renameBank(bankId, newName)) { + if (!bankOpRename(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; } - persistBankOp("ReaSampler: rename bank"); invalidatePanel(); } @@ -177,11 +152,11 @@ void doDeleteBank(const std::string& bankId) { } // r == 6 (Yes) falls through to a plain delete (drops members). } - if (!book()->deleteBank(bankId)) return; // S9: bump when the bank held samples (either the Yes-drop path or the No-evacuate-then- // delete path moved/dropped members) — both change what a live instance could play. An - // empty-bank delete is purely organizational, no bump. - persistBankOp("ReaSampler: delete bank", /*bumpGeneration=*/members > 0); + // empty-bank delete is purely organizational, no bump. The ORIGINAL member count decides + // (the No-path evacuated them moments ago, but the membership still changed). + if (!bankOpDelete(bankId, /*bumpGeneration=*/members > 0)) return; // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; @@ -192,79 +167,39 @@ void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; - if (!book()->evacuate(bankId)) return; - persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); // S9: membership changed + if (!bankOpEvacuate(bankId)) return; invalidatePanel(); } void doActivateBank(const std::string& bankId) { - if (!book()) return; - if (!book()->setActiveBank(bankId)) return; // rejects an unknown id - persistBankOp("ReaSampler: activate bank"); + if (!bankOpActivate(bankId)) return; // rejects an unknown id invalidatePanel(); } } // namespace -// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass -// ids straight to the model op (no BankModel& cached across the loop's mutations). -// -// NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): -// * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally -// before the dest add collapses on hash), so the index DID mutate — counts. -// * COPY collapse: the source is left intact AND the dest already held the hash, -// so NOTHING changed — a true index no-op. Must NOT open an undo point. -// Hence: copy counts only real gains (Copied); move counts gains OR collapses. +// Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel +// skin over bankOpTransfer (the one-home verb owns the loop, the verb-aware no-op +// guardrail, and the undo-batched persist); this layer clears the stale selection +// and repaints on an actual mutation. void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { - if (!book()) return; - if (sampleIds.empty() || srcBankId == destBankId) return; - if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; - int ok = 0, collapsed = 0; - for (const std::string& sid : sampleIds) { - const TransferResult r = - copy ? book()->copySample(sid, srcBankId, destBankId) - : book()->moveSample(sid, srcBankId, destBankId); - switch (r) { - case TransferResult::Moved: - case TransferResult::Copied: ++ok; break; - case TransferResult::Collapsed: ++collapsed; break; - case TransferResult::RejectedUnknownBank: - case TransferResult::RejectedSampleAbsent: - case TransferResult::RejectedSameBank: break; - } - } - const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); - if (!mutated) return; // nothing changed — no persist, no undo point - - const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; - persistBankOp(label, /*bumpGeneration=*/true); // S9: bank membership changed + if (!bankOpTransfer(sampleIds, srcBankId, destBankId, copy)) + return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } -// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). 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). Removes are silent -// (no confirm dialog); recoverability is provided by the batched REAPER undo (R-B) — -// one Ctrl-Z restores the index entry. Ids passed by value — no BankModel& cached -// across the loop's mutations. +// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin +// over bankOpRemove — see the verb for the never-deletes-bytes / silent-remove / +// one-Ctrl-Z contract. Clears the stale selection and repaints on an actual removal. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { - if (!book() || sampleIds.empty()) return; - if (!book()->bank(srcBankId)) return; - - int removed = 0; - for (const std::string& sid : sampleIds) - if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == - RemoveResult::Removed) - ++removed; - if (removed == 0) return; // nothing changed — no persist, no undo point - - persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); // S9: sample dropped + if (!bankOpRemove(sampleIds, srcBankId)) + return; // nothing changed — no persist, no undo point // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; @@ -471,10 +406,189 @@ void showSelectionMenu(int screenX, int screenY) { } // namespace reasampler::panel -// --- Public API (the selection read seam — panel_bank_ops.h) ------------------- +// --- Public API (panel_bank_ops.h) --------------------------------------------- namespace reasampler { +namespace { + +// Persists the book after a bank mutation. Mirrors the CAPTURE path, NOT the +// Design-View path: quiet persist — saveToActiveProject no-ops on an unsaved project +// (the change stays valid for the session and persists on the user's next save). +// Deliberately NO Save-As prompt; do not "align" with persistViewState's prompt +// idiom. Returns whether a persist actually happened, so persistBankOp can discard +// its undo block when nothing was written. Session pointer is live for the whole +// extension lifetime (bankPanelInit at load, before any action registers). +bool persistBook() { return panel::g_panel.session->saveToActiveProject(); } + +// 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); +} + +} // namespace + +// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp byte-identical twins. +// COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', +// so the return separator is overridden to \x1f (un-typeable) via the documented +// `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. +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; +} + +// Persists a completed bank-index verb 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. +// +// 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) panel::g_panel.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 +} + +// --- Promptless inner bank verbs (Q-W4 single home) ---------------------------- +// Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges. Each +// verb resolves the book fresh (panel::book(), null when no live session) and +// persists ONLY after the model accepted — a rejected op opens no undo point. + +std::string bankOpCreate(const std::string& name) { + BankBook* b = panel::book(); + if (!b) return {}; + const std::string id = mintBankId(); + if (!b->createBank(id, name)) return {}; // duplicate display name (model rule) + persistBankOp("ReaSampler: create bank"); + return id; +} + +bool bankOpRename(const std::string& bankId, const std::string& newName) { + BankBook* b = panel::book(); + if (!b || !b->renameBank(bankId, newName)) return false; // pool / name in use + persistBankOp("ReaSampler: rename bank"); + return true; +} + +bool bankOpDelete(const std::string& bankId, bool bumpGeneration) { + BankBook* b = panel::book(); + if (!b || !b->deleteBank(bankId)) return false; // pool un-deletable (model rule) + persistBankOp("ReaSampler: delete bank", bumpGeneration); + return true; +} + +bool bankOpEvacuate(const std::string& bankId) { + BankBook* b = panel::book(); + if (!b || !b->evacuate(bankId)) return false; // pool is a destination, not a source + // S9: evacuate moves members between banks (bank membership changes) -> bump. + persistBankOp("ReaSampler: evacuate bank", /*bumpGeneration=*/true); + return true; +} + +bool bankOpActivate(const std::string& bankId) { + BankBook* b = panel::book(); + if (!b || !b->setActiveBank(bankId)) return false; // rejects an unknown id + persistBankOp("ReaSampler: activate bank"); + return true; +} + +// 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; move counts gains OR collapses. Ids pass +// straight to the model op — no BankModel& cached across the loop's mutations. +bool bankOpTransfer(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy) { + BankBook* b = panel::book(); + if (!b || sampleIds.empty() || srcBankId == destBankId) return false; + if (!b->bank(srcBankId) || !b->bank(destBankId)) return false; + int ok = 0, collapsed = 0; + for (const std::string& sid : sampleIds) { + const TransferResult r = + copy ? b->copySample(sid, srcBankId, destBankId) + : b->moveSample(sid, srcBankId, destBankId); + switch (r) { + case TransferResult::Moved: + case TransferResult::Copied: ++ok; break; + case TransferResult::Collapsed: ++collapsed; break; + case TransferResult::RejectedUnknownBank: + case TransferResult::RejectedSampleAbsent: + case TransferResult::RejectedSameBank: break; + } + } + const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); + if (!mutated) return false; // nothing changed — no persist, no undo point + // 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(copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", + /*bumpGeneration=*/true); + return true; +} + +// Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks +// stays latent in the model). 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). Silent: recoverability is the batched undo (R-B). +bool bankOpRemove(const std::vector& sampleIds, + const std::string& srcBankId) { + BankBook* b = panel::book(); + if (!b || sampleIds.empty() || !b->bank(srcBankId)) return false; + int removed = 0; + for (const std::string& sid : sampleIds) + if (b->removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return false; // every id already absent — no undo point + // 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. + persistBankOp("ReaSampler: remove sample(s)", /*bumpGeneration=*/true); + return true; +} + +// --- Selection read seam -------------------------------------------------------- + std::vector bankPanelSelectedSampleIds() { return panel::focusedSelectionIds(); } diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index 6853129..afe98b6 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -1,19 +1,91 @@ #pragma once // panel_bank_ops — the bank-CRUD + selection-read seam of the bank panel (Q-W2 split -// of bank_panel.h; Phase B4/B5). The .cpp is the single home of the panel-side bank -// verbs (create / rename / delete / evacuate / activate / move / copy / remove), -// each driven against the B1 BankBook model on the session and persisted via -// persistBankOp (one bank op = one Ctrl-Z) — the owner Q-W4 dedupes actions.cpp -// against. This header carries the panel's public selection-read surface. +// of bank_panel.h; Phase B4/B5). The .cpp is the SINGLE implementation home of the +// bank verbs (create / rename / delete / evacuate / activate / move / copy / remove): +// each promptless inner verb below drives the B1 BankBook model on the session and +// persists via persistBankOp (one bank op = one Ctrl-Z). Q-W4 dedupe: the panel's +// menu handlers and the bank_actions bindable family are both thin UX skins +// (prompts / confirms / console vs. message boxes / panel-state nudges) over these +// one-home verbs. This header carries that verb surface, the shared prompt/persist +// helpers, and the panel's public selection-read surface. // -// REAPER-free: main.cpp (insert action) and actions.cpp read the selection through -// these free functions. +// The selection reads are REAPER-free; the verbs and helpers are REAPER-facing +// (persist + stock dialogs) but SDK-free in this header. #include #include namespace reasampler { +// --- Promptless inner bank verbs (Q-W4 single home) -------------------------- +// Each verb: model op on the session's BankBook + persistBankOp (undo-batched +// ext-state persist) — NO prompts, NO message boxes, NO panel-state nudges. The +// caller owns all UX. Every verb returns whether the model accepted the mutation +// (a rejected op persists nothing and opens no undo point). Verbs resolve the +// session via the panel's live session pointer (set at load by bankPanelInit, +// before any action can fire) and fail safe (false / "") when it is absent. + +// Mints a stable GUID bank id, creates `name` in the book. Returns the new bank id, +// or "" when the model rejects the name (duplicate, trimmed + case-insensitive). +// Create is purely organizational — no generation bump. +std::string bankOpCreate(const std::string& name); + +// Renames `bankId`. False when the model rejects (pool un-renamable / name in use). +bool bankOpRename(const std::string& bankId, const std::string& newName); + +// Deletes `bankId`. False when the model rejects (pool un-deletable). The caller +// passes `bumpGeneration` from the member count it read BEFORE any evacuate/delete +// (an evacuate-then-delete flow must still bump on the ORIGINAL membership). +bool bankOpDelete(const std::string& bankId, bool bumpGeneration); + +// Evacuates `bankId`'s members to the pool. False when the model rejects (the pool +// itself). Bumps the generation (membership changed). +bool bankOpEvacuate(const std::string& bankId); + +// Activates `bankId` as the capture target. False on an unknown id. No bump. +bool bankOpActivate(const std::string& bankId); + +// Moves (copy=false) or copies (copy=true) `sampleIds` from `srcBankId` to +// `destBankId` (index-only; files never relocate). Returns whether the index +// actually mutated — the verb-aware no-op guardrail: a COPY collapse changes +// nothing (no undo point); a MOVE collapse did remove the source entry (counts). +// Persists ONE undo point ("move/copy sample(s)") only when mutated. +bool bankOpTransfer(const std::vector& sampleIds, + const std::string& srcBankId, const std::string& destBankId, + bool copy); + +// Removes `sampleIds` from `srcBankId` (index-only, this-bank scope; never deletes +// bytes). Returns whether anything was removed; persists one undo point when so. +bool bankOpRemove(const std::vector& sampleIds, + const std::string& srcBankId); + +// --- Shared UX/persist helpers ------------------------------------------------ + +// Prompts the user for a single line of text via REAPER's stock input dialog +// (GetUserInputs). `initial` pre-fills the field. Returns false (leaving `out` +// untouched) on cancel or an empty entry. COMMA GUARD: the return separator is +// overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, +// so any printable name — commas included — round-trips whole (SDK ~3806/3808). +// One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp twins. +bool promptText(const char* title, const char* caption, const std::string& initial, + std::string& out); + +// Persists a completed bank-index verb as a single REAPER undo point (R-B). +// Wraps the session persist (SetProjExtState) in a Begin/End block with +// UNDO_STATE_MISCCFG so the bank op is one Ctrl-Z. On an unsaved / no-active project +// the persist no-ops and the block is closed with an empty label + zero flag (REAPER +// discards it). Callers must invoke this ONLY after a successful/effective mutation — +// rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching +// here so no empty undo point is ever opened for a no-op. +// +// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a +// live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave +// it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate / +// reorder. The bump (when requested) happens INSIDE the block, BEFORE the persist, so +// the stamped counter rides the same ext-state write and undo captures the pre/post +// generation with the rest of the blob. +void persistBankOp(const char* label, bool bumpGeneration = false); + // The stable ids of the currently-selected samples, in bank (insertion) order. // Empty when nothing is selected or the panel has never opened. This is the clean // seam the `insert` action reads to know WHAT to place — it returns ids (not grid diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 68fb38e..f2999b2 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -20,7 +20,7 @@ #include "shell/panel/panel_state.h" -#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B) +#include "shell/panel/panel_bank_ops.h" // persistBankOp — shared undo-block wrapper (R-B) #include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11) #include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop (S17) diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 5163172..84243fb 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -17,7 +17,7 @@ #include "shell/panel/panel_state.h" #include "shell/panel/panel_input.h" -#include "actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) +#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch (R3) #include "persist.h" // ReaSamplerSession — view/tail reads + mutation #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2) #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B) diff --git a/src/shell/panel/panel_layout.cpp b/src/shell/panel/panel_layout.cpp index 320c644..b1a2e30 100644 --- a/src/shell/panel/panel_layout.cpp +++ b/src/shell/panel/panel_layout.cpp @@ -217,7 +217,7 @@ std::string activeModeIdOrEmpty() { // The BOTTOM toolbar inventory (L5 refinement 3): FOUR Item/Track x Arrange/Design tag buttons // then a set-apart Show Both. The suffixes are the ACTUAL registered command-id strings from -// actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; +// design_view_actions.cpp (VIEW_MOVE_ITEMS_ARRANGE / VIEW_MOVE_ITEMS_DESIGN for the item moves; // VIEW_TAG_ARRANGE / VIEW_TAG_DESIGN for the track tags; VIEW_SHOW_BOTH) — grepped, not // paraphrased. "…: Arrange" routes through the untag/arrange path (Arrange = absence of a tag). // The Toggle + both Activate buttons are REMOVED (L5 refinement 4 / settled inventory): the diff --git a/src/shell/panel/panel_layout.h b/src/shell/panel/panel_layout.h index ef81c1a..72ac6ea 100644 --- a/src/shell/panel/panel_layout.h +++ b/src/shell/panel/panel_layout.h @@ -6,7 +6,7 @@ // row/cluster builders, region rects, the L7 slot-order display bridge) is internal to // panel_layout.cpp (see panel_state.h for the intra-panel seam). // -// REAPER-free: main.cpp / actions.cpp drive these through plain free functions. +// REAPER-free: main.cpp / bank_actions.cpp drive these through plain free functions. namespace reasampler { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index e328222..16fdefc 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -16,10 +16,11 @@ // * Explicit using-declarations pulling the pure modules' symbols into // reasampler::panel from their REAL namespace homes (Q-W1 sub-namespaces) — this // header itself does not directly include the interim core/namespaces.h shim -// (Q-W2 retires that direct dependency for this module). Six of the eight panel -// TUs still pull the shim in TRANSITIVELY via actions.h/persist.h/ingest.h/ -// draw_kit.h/view.h; only panel_thumbnails.cpp and panel_audition.cpp are -// shim-free end to end. Nothing HERE depends on it either way. +// (Q-W2 retires that direct dependency for this module; Q-W4 retired the +// actions.h carrier with the actions split). Several panel TUs still pull the +// shim in TRANSITIVELY via persist.h/ingest.h/draw_kit.h/view.h; only +// panel_thumbnails.cpp and panel_audition.cpp are shim-free end to end. +// Nothing HERE depends on it either way. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, From 4f587258b224a6d792900686ad89ad85f147e79f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 13:06:12 -0400 Subject: [PATCH 2/2] fix: guard persistBankOp/persistBook against a null session; rename promptText to promptBankName --- src/shell/actions/bank_actions.cpp | 16 ++++++++-------- src/shell/panel/panel_bank_ops.cpp | 25 ++++++++++++++++++------- src/shell/panel/panel_bank_ops.h | 10 ++++++++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/shell/actions/bank_actions.cpp b/src/shell/actions/bank_actions.cpp index 67ddf94..0defb26 100644 --- a/src/shell/actions/bank_actions.cpp +++ b/src/shell/actions/bank_actions.cpp @@ -1,7 +1,7 @@ // bank_actions.cpp — the multi-bank bindable action family (Phase B3; Q-W4 split of // actions.cpp). See bank_actions.h. // -// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptText), +// Q-W4 dedupe: each mutating handler is a THIN UX SKIN — text prompts (promptBankName), // name resolution, and console feedback — over the promptless bankOp* inner verbs // homed in panel_bank_ops (model op + persistBankOp, one bank op = one Ctrl-Z). The // book's rules (pool privileges, collapse-by-hash, active-fallback-to-pool) all live @@ -28,7 +28,7 @@ #include "core/model/bank_book.h" // BankBook, nextBankId, kPoolBankId (B1) #include "persist.h" // ReaSamplerSession (owns book()) -#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptText + selection seam +#include "shell/panel/panel_bank_ops.h" // bankOp* inner verbs + promptBankName + selection seam #include "shell/panel/panel_layout.h" // full-height toggles (B3) #define REAPERAPI_MINIMAL @@ -115,7 +115,7 @@ std::string bankIdByDisplayName(const std::string& name) { // 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; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; if (bankOpCreate(name).empty()) { ShowConsoleMsg( ("ReaSampler: could not create bank \"" + name + @@ -129,7 +129,7 @@ void doBankCreate() { // self-contained; the panel renames in place on a tab. void doBankRename() { std::string which; - if (!promptText("ReaSampler: rename bank", "Bank to rename (current name):", "", + if (!promptBankName("ReaSampler: rename bank", "Bank to rename (current name):", "", which)) return; const std::string id = bankIdByDisplayName(which); @@ -138,7 +138,7 @@ void doBankRename() { return; } std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", which, newName)) return; + if (!promptBankName("ReaSampler: rename bank", "New name:", which, newName)) return; if (!bankOpRename(id, newName)) { // The verb rejects the pool (un-renamable) or a name already used by another // bank (unique display names, trimmed + case-insensitive). @@ -154,7 +154,7 @@ void doBankRename() { // panel confirm (naming evacuate inline, with a one-click evacuate) lives in the panel. void doBankDelete() { std::string which; - if (!promptText("ReaSampler: delete bank", "Bank to delete:", "", which)) return; + if (!promptBankName("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()); @@ -194,7 +194,7 @@ void doBankDelete() { // intended "keep the samples" companion to delete. void doBankEvacuate() { std::string which; - if (!promptText("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", + if (!promptBankName("ReaSampler: evacuate bank", "Bank to evacuate to the pool:", "", which)) return; const std::string id = bankIdByDisplayName(which); @@ -245,7 +245,7 @@ void doBankTransferSelected(bool copy) { 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; + if (!promptBankName(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()); diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index d3d8b3b..497c326 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -95,7 +95,7 @@ std::vector namedBanks() { void doCreateBank() { if (!book()) return; std::string name; - if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; + if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = bankOpCreate(name); if (id.empty()) { ShowMessageBox("A bank with that name already exists.", @@ -115,7 +115,7 @@ void doRenameBank(const std::string& bankId) { if (!bk || bk->isPool()) return; const std::string current = bk->displayName; // copy before any mutation std::string newName; - if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; + if (!promptBankName("ReaSampler: rename bank", "New name:", current, newName)) return; if (!bankOpRename(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); @@ -417,9 +417,13 @@ namespace { // (the change stays valid for the session and persists on the user's next save). // Deliberately NO Save-As prompt; do not "align" with persistViewState's prompt // idiom. Returns whether a persist actually happened, so persistBankOp can discard -// its undo block when nothing was written. Session pointer is live for the whole -// extension lifetime (bankPanelInit at load, before any action registers). -bool persistBook() { return panel::g_panel.session->saveToActiveProject(); } +// its undo block when nothing was written. Guards a null session pointer (false, +// no-op) — see persistBankOp's guard below for why this is defensive rather than +// dead code. +bool persistBook() { + if (!panel::g_panel.session) return false; // no live session: nothing to persist + return panel::g_panel.session->saveToActiveProject(); +} // 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). @@ -438,8 +442,8 @@ std::string mintBankId() { // COMMA GUARD: GetUserInputs splits returned values on a separator defaulting to ',', // so the return separator is overridden to \x1f (un-typeable) via the documented // `separator=X` trailing pseudo-caption (SDK ~3806) — any printable name round-trips. -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out) { +bool promptBankName(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()); @@ -472,7 +476,14 @@ bool promptText(const char* title, const char* caption, const std::string& initi // 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. +// +// NULL-SESSION GUARD: this is a public API (panel_bank_ops.h) with callers outside +// this TU (e.g. panel_drag.cpp), not all of which are guaranteed to have re-checked +// the session pointer immediately beforehand. Bail out BEFORE Undo_BeginBlock2 — no +// block is opened, so there is nothing to balance and no risk of an unbalanced +// Begin/End pair. void persistBankOp(const char* label, bool bumpGeneration) { + if (!panel::g_panel.session) return; // no live session: no-op, no undo point opened 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() -> diff --git a/src/shell/panel/panel_bank_ops.h b/src/shell/panel/panel_bank_ops.h index afe98b6..2f1c5ac 100644 --- a/src/shell/panel/panel_bank_ops.h +++ b/src/shell/panel/panel_bank_ops.h @@ -67,8 +67,8 @@ bool bankOpRemove(const std::vector& sampleIds, // overridden to \x1f (un-typeable) via the documented `separator=X` pseudo-caption, // so any printable name — commas included — round-trips whole (SDK ~3806/3808). // One home (Q-W4) for the former actions.cpp/panel_bank_ops.cpp twins. -bool promptText(const char* title, const char* caption, const std::string& initial, - std::string& out); +bool promptBankName(const char* title, const char* caption, const std::string& initial, + std::string& out); // Persists a completed bank-index verb as a single REAPER undo point (R-B). // Wraps the session persist (SetProjExtState) in a Begin/End block with @@ -78,6 +78,12 @@ bool promptText(const char* title, const char* caption, const std::string& initi // rejected ops (duplicate name, un-deletable pool, etc.) must return before reaching // here so no empty undo point is ever opened for a no-op. // +// NULL-SESSION GUARD: this is a public API with callers outside panel_bank_ops.cpp +// (e.g. panel_drag.cpp). If the panel's session pointer is absent (no live session), +// this is a no-op — no undo block is opened. Today every real caller only reaches +// here via a prior session-backed check, so the guard is not yet reachable in +// practice; it exists to make the function safe to call standalone. +// // S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a // live instance would PLAY — move / copy / remove / evacuate / delete-with-members. Leave // it false (the default) for a PURELY ORGANIZATIONAL verb — create / rename / activate /