feat(banks): bindable multi-bank action family — create/rename/delete/evacuate/activate/move/copy + full-height toggles (B3)

This commit is contained in:
2026-07-24 05:35:16 -04:00
parent 288c1e44e4
commit cbfc13c4e0
8 changed files with 546 additions and 1 deletions
+360 -1
View File
@@ -24,7 +24,9 @@
#include <string>
#include <vector>
#include "persist.h" // ReaSamplerSession (owns view() model)
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
#include "bank_panel.h" // selection seam + full-height toggles (B3/B4)
#include "persist.h" // ReaSamplerSession (owns book() + view() model)
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "view.h" // applyMode (D2 shell)
#include "view_mode_model.h"
@@ -37,6 +39,10 @@
#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
#include "reaper_plugin_functions.h"
namespace reasampler {
@@ -271,4 +277,357 @@ void designViewUnregisterActions(reaper_plugin_info_t* rec) {
g_session = nullptr;
}
// ===========================================================================
// Multi-bank action family (Phase B3)
// ===========================================================================
//
// Each action drives the B1 model on g_session->book() and persists via
// g_session->saveToActiveProject() so the change travels with the .rpp — exactly as
// the capture path persists a new Sample (main.cpp RunCapture). The book's rules
// (pool privileges, collapse-by-hash, active-fallback-to-pool) all live in bank_book;
// these handlers only call the model and react to the boolean / TransferResult.
//
// REFERENCE-INVALIDATION GUARDRAIL (B2 review): book().activeIndex() / bank()->index
// return a reference INTO the book's internal vector, which a create/delete can
// reallocate. No handler here caches a BankIndex& (or a Bank*) across a structural
// mutation — each resolves ids to strings up front and re-resolves after any
// create/delete. Move/copy pass ids (not references) straight to moveSample/copySample.
namespace {
// FOREVER-STABLE multi-bank action-id strings. Same CEREBELLUM_REASAMPLER_ family
// prefix; each is minted into a persistent command id user keybindings key off —
// NEVER change these after ship.
constexpr const char* kIdBankCreate = "CEREBELLUM_REASAMPLER_BANK_CREATE";
constexpr const char* kIdBankRename = "CEREBELLUM_REASAMPLER_BANK_RENAME";
constexpr const char* kIdBankDelete = "CEREBELLUM_REASAMPLER_BANK_DELETE";
constexpr const char* kIdBankEvacuate = "CEREBELLUM_REASAMPLER_BANK_EVACUATE";
constexpr const char* kIdBankActivateNext = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_NEXT";
constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL";
constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED";
constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED";
constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT";
constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT";
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_cmdBankPoolFull = 0;
int g_cmdBankBanksFull = 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_accelBankPoolFull{};
gaccel_register_t g_accelBankBanksFull{};
// Persists the book after a bank mutation. Mirrors the capture path (main.cpp
// RunCapture): a bank change is held in-session and written to the active project's
// ext state so it travels with the .rpp. No Save-As prompt here — saveToActiveProject
// no-ops on an unsaved project (the change stays valid for the session and persists
// on the user's next save), matching how capture persists.
void persistBook() { g_session->saveToActiveProject(); }
// 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.
bool promptText(const char* title, const char* caption, const std::string& initial,
std::string& out) {
std::vector<char> 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());
if (!GetUserInputs(title, 1, caption, buf.data(), static_cast<int>(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. Case-sensitive 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.
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). A duplicate-name is allowed
// (display names are not unique in the model); the fresh GUID keeps the id unique.
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 (id collision — try again).\n");
return;
}
persistBook();
ShowConsoleMsg(("ReaSampler: created bank \"" + name + "\".\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; 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)) {
ShowConsoleMsg("ReaSampler: cannot rename that bank (the pool is un-renamable).\n");
return;
}
persistBook();
ShowConsoleMsg(("ReaSampler: renamed \"" + which + "\" -> \"" + newName + "\".\n")
.c_str());
}
// 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;
}
// 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;
}
persistBook();
ShowConsoleMsg(("ReaSampler: deleted bank \"" + which + "\".\n").c_str());
}
// 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;
}
persistBook();
ShowConsoleMsg(("ReaSampler: evacuated \"" + which + "\" to the pool.\n").c_str());
}
// 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<std::string> 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;
persistBook();
const Bank* b = g_session->book().bank(target);
ShowConsoleMsg(("ReaSampler: active bank -> \"" +
(b ? b->displayName : target) + "\".\n")
.c_str());
}
// 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;
persistBook();
ShowConsoleMsg("ReaSampler: active bank -> \"Pool\".\n");
}
// Move or copy the panel's selected samples from the ACTIVE bank into a named
// destination bank (prompted by display name). The panel grid shows the active bank,
// so its selection ids are members of the active bank — that is the source. 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 will
// drive 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<std::string> 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 active bank (what the panel grid shows). Pass ids by value — no
// BankIndex& is cached across the loop's mutations.
const std::string srcId = g_session->book().activeBankId();
if (srcId == destId) {
ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n");
return;
}
int ok = 0, collapsed = 0, absent = 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;
case TransferResult::RejectedSampleAbsent: ++absent; break;
// Unknown-bank / same-bank are pre-checked above; treat defensively as no-ops.
case TransferResult::RejectedUnknownBank:
case TransferResult::RejectedSameBank: break;
}
}
persistBook();
std::string log = std::string("ReaSampler: ") + verb + " -> \"" + destName +
"\": " + std::to_string(ok) + " " + verb + "d";
if (collapsed) log += ", " + std::to_string(collapsed) + " collapsed on hash";
if (absent) log += ", " + std::to_string(absent) + " no longer present";
log += ".\n";
ShowConsoleMsg(log.c_str());
}
} // namespace
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,
"ReaSampler: create bank");
g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename,
"ReaSampler: rename bank");
g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete,
"ReaSampler: delete bank");
g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate,
"ReaSampler: evacuate bank to pool");
g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext,
"ReaSampler: activate next bank (cycle)");
g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool,
"ReaSampler: activate pool");
g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel,
"ReaSampler: move selected samples to bank");
g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel,
"ReaSampler: copy selected samples to bank");
g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull,
"ReaSampler: toggle pool full-height");
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
"ReaSampler: toggle banks full-height");
}
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_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
return false; // not ours — caller's hookcommand keeps looking
}
void bankUnregisterActions(reaper_plugin_info_t* rec) {
// Mirror-unregister with '-'-prefixed strings, reverse of registration order.
rec->Register("-gaccel", (void*)&g_accelBankBanksFull);
rec->Register("-command_id", (void*)kIdBankBanksFull);
rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
rec->Register("-command_id", (void*)kIdBankPoolFull);
rec->Register("-gaccel", (void*)&g_accelBankCopySel);
rec->Register("-command_id", (void*)kIdBankCopySel);
rec->Register("-gaccel", (void*)&g_accelBankMoveSel);
rec->Register("-command_id", (void*)kIdBankMoveSel);
rec->Register("-gaccel", (void*)&g_accelBankActivatePool);
rec->Register("-command_id", (void*)kIdBankActivatePool);
rec->Register("-gaccel", (void*)&g_accelBankActivateNext);
rec->Register("-command_id", (void*)kIdBankActivateNext);
rec->Register("-gaccel", (void*)&g_accelBankEvacuate);
rec->Register("-command_id", (void*)kIdBankEvacuate);
rec->Register("-gaccel", (void*)&g_accelBankDelete);
rec->Register("-command_id", (void*)kIdBankDelete);
rec->Register("-gaccel", (void*)&g_accelBankRename);
rec->Register("-command_id", (void*)kIdBankRename);
rec->Register("-gaccel", (void*)&g_accelBankCreate);
rec->Register("-command_id", (void*)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