// panel_bank_ops.cpp — the bank-CRUD-UX + menus seam of the docked bank panel. The // promptless bank verbs live in shell/bank_ops (bankOp* + persistBankOp); this TU is // the panel's THIN UX SKIN over them — menu handlers, book/bank accessors, popup // menus, and the selection-id / OS-drag path resolvers. `bank_actions` is the // sibling bindable-action skin. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers. DAW-verified, not // unit-tested. #include #include #include #include #include "shell/panel/panel_state.h" #include "shell/panel/panel_bank_ops.h" #include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin #include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row #include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs #include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_Main_OnCommand #include "reaper_plugin_functions.h" namespace reasampler::panel { namespace fs = std::filesystem; // Mirrors the persist shell's derivation (ext_state_io.cpp). std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); std::string rpp(buf.data()); if (rpp.empty()) return {}; return normalizeSlashes(fs::path(rpp).parent_path().string()); } BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } // The BankModel a region currently displays. Pool region -> the pool; banks region -> // the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved // FRESH every call (never cached across a mutation). const BankModel* indexForRegion(Region r) { BankBook* b = book(); if (!b) return nullptr; if (r == Region::Pool) return &b->pool().index; if (g_panel.shownBankId.empty()) return nullptr; return b->index(g_panel.shownBankId); } // The bank id a region displays (pool id, or the shown tab's id; "" when none). std::string bankIdForRegion(Region r) { if (r == Region::Pool) return std::string(kPoolBankId); return g_panel.shownBankId; } // The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. std::vector namedBanks() { std::vector out; BankBook* b = book(); if (!b) return out; for (const Bank& bk : b->banks()) if (!bk.isPool()) out.push_back(&bk); return out; } // Bank management ops (id-keyed; THIN UX SKINS over the bankOp* verbs). After a // STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankModel& is invalid — we // resolve fresh, pass ids, and let the next refreshFingerprint repaint. void doCreateBank() { if (!book()) return; std::string name; if (!promptBankName("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = bankOpCreate(*g_panel.session, 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; invalidatePanel(); } namespace { void doRenameBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptBankName("ReaSampler: rename bank", "New name:", current, newName)) return; if (!bankOpRename(*g_panel.session, bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; } invalidatePanel(); } // Delete with a confirm-on-non-empty affordance: the confirm names the member count // and offers evacuate as the one-click alternative (Yes=delete anyway, // No=evacuate-then-keep, Cancel=abort). void doDeleteBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::size_t members = bk->index.size(); // read BEFORE any mutation const std::string name = bk->displayName; if (members > 0) { const std::string msg = "\"" + name + "\" holds " + std::to_string(members) + (members == 1 ? " sample" : " samples") + ".\n\nYes -- delete the bank AND drop its samples (files are kept on disk " "but no bank references them until prune).\nNo -- Evacuate them to the " "pool first, then delete the empty bank (keeps the samples).\nCancel -- " "do nothing."; // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 3); if (r == 2) return; // Cancel if (r == 7) { // No -> evacuate, then delete empty if (!book()->evacuate(bankId)) return; // book() may have reallocated; re-resolve nothing (we pass the id again). } // r == 6 (Yes) falls through to a plain delete (drops members). } // Bump generation when the bank held samples — an empty-bank delete is purely // organizational. The ORIGINAL member count decides (the No-path already evacuated them). if (!bankOpDelete(*g_panel.session, bankId, /*bumpGeneration=*/members > 0)) return; // 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; invalidatePanel(); } void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; if (!bankOpEvacuate(*g_panel.session, bankId)) return; invalidatePanel(); } void doActivateBank(const std::string& bankId) { if (!book()) return; // no live session — nothing to activate against if (!bankOpActivate(*g_panel.session, bankId)) return; // rejects an unknown id invalidatePanel(); } } // namespace // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Thin panel // skin over bankOpTransfer; 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; // no live session — nothing to transfer within if (!bankOpTransfer(*g_panel.session, sampleIds, srcBankId, destBankId, copy)) return; // nothing changed — no persist, no undo point // Selection indexed into the source; after a move those indices are stale. g_panel.selection = Selection{}; invalidatePanel(); } // Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Thin panel skin // over bankOpRemove. Clears the stale selection and repaints on an actual removal. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { if (!book()) return; // no live session — nothing to remove from if (!bankOpRemove(*g_panel.session, sampleIds, srcBankId)) return; // nothing changed — no persist, no undo point g_panel.selection = Selection{}; invalidatePanel(); } // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Selection ordinals index the DISPLAY (slot) order, not BankModel // insertion order. Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { std::vector ids; const RegionDisplay disp = focusedDisplay(); const int count = disp.occupiedCount(); for (int i : g_panel.selection.indices) if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); return ids; } // Resolves the ARMED drag payload to the absolute, existing-file path list for a native // OS drag-out. Reuses resolveBankFile (audition/insert's path machinery) — no temp // copies. Each id is looked up in its SOURCE bank's index (not the focused region, which // can differ once the pointer roams), then handed to drag_out::assemblePathList for // dedupe + skip-missing/unresolved policy. Read-only. std::vector resolveDragPathsForOs() { std::vector resolved; BankBook* b = book(); if (!b) return {}; const BankModel* idx = b->index(g_panel.dragSourceBankId); if (!idx) return {}; const std::string projectDir = currentProjectDir(); resolved.reserve(g_panel.dragSampleIds.size()); for (const std::string& sid : g_panel.dragSampleIds) { const Sample* s = idx->query(sid); if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve ResolvedSample rs; rs.absolutePath = resolveBankFile(projectDir, s->relativePath); rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath)); resolved.push_back(std::move(rs)); } return assemblePathList(resolved).paths; } // SWELL/Win32 both expose CreatePopupMenu / InsertMenu / TrackPopupMenu(TPM_RETURNCMD) / // DestroyMenu. Menu command ids below are LOCAL to the popup (not REAPER action ids) — // TPM_RETURNCMD hands the chosen id straight back, so no hookcommand routing is involved. namespace { // Win32 and SWELL both treat pos < 0 as append. void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { UINT flags = MF_BYPOSITION | MF_STRING; if (grayed) flags |= MF_GRAYED; InsertMenu(menu, -1, flags, id, text); } void menuSeparator(HMENU menu) { InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); } // Menu command ids (local to a popup). enum : unsigned int { kMenuNone = 0, kMenuActivate = 100, kMenuRename, kMenuDelete, kMenuEvacuate, kMenuCreate, kMenuExport, // export this bank as a .rsbank package kMenuRemove, // remove selected sample(s) from the source bank kMenuImportPackage, // land a .rsbank as a NEW bank (never merges into this one) kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; } // namespace // Shows the right-click context menu for a named-bank TAB: activate / rename / delete // / evacuate / export that bank, plus a create entry. Drives the id-keyed ops. void showTabMenu(int screenX, int screenY, const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const bool isActive = book()->activeBankId() == bankId; const bool nonEmpty = !bk->index.empty(); HMENU menu = CreatePopupMenu(); menuAppend(menu, kMenuActivate, isActive ? "Active (capture target)" : "Activate (make capture target)", /*grayed=*/isActive); menuSeparator(menu); menuAppend(menu, kMenuRename, "Rename..."); menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); menuAppend(menu, kMenuDelete, "Delete..."); menuAppend(menu, kMenuExport, "Export as package..."); menuSeparator(menu); menuAppend(menu, kMenuCreate, "New bank..."); menuAppend(menu, kMenuImportPackage, "Import bank package..."); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); switch (cmd) { case kMenuActivate: doActivateBank(bankId); break; case kMenuRename: doRenameBank(bankId); break; case kMenuEvacuate: doEvacuateBank(bankId); break; case kMenuDelete: doDeleteBank(bankId); break; case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break; case kMenuCreate: doCreateBank(); break; // Always a NEW bank, never a merge into the right-clicked one — the row sits // here because this is the panel's bank menu, not because it targets this bank. case kMenuImportPackage: if (g_panel.session) { const std::string id = doImportBankPackage(*g_panel.session); if (!id.empty()) { // landed — show the freshly-imported bank g_panel.shownBankId = id; g_panel.focusedRegion = Region::Banks; invalidatePanel(); } } break; default: break; } } // Opens the top-toolbar overflow ("⋯" More) popup and fires the chosen rare-capture // variant's command. Menu ids are LOCAL to the popup (1-based ordinal into // overflowMenuRows); we resolve + fire the corresponding registered command id via // the same contract the visible buttons use. void showMoreMenu() { if (!g_panel.hwnd) return; const std::vector rows = overflowMenuRows(); if (rows.empty()) return; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const MenuButtonRect mb = topMenuButtonRect(cr.right - cr.left); if (mb.empty()) return; HMENU menu = CreatePopupMenu(); for (std::size_t i = 0; i < rows.size(); ++i) { const int cmd = resolveBarCommandId(rows[i]); // Grey a variant not registered on this channel (defensive — all three are registered). menuAppend(menu, static_cast(i + 1), rows[i].fullName.c_str(), /*grayed=*/cmd == 0); } // Anchor the popup at the button's bottom-left, in screen coords. POINT pt{mb.x, mb.y + mb.height}; ClientToScreen(g_panel.hwnd, &pt); const int chosen = TrackPopupMenu(menu, TPM_RETURNCMD, pt.x, pt.y, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); if (chosen >= 1 && chosen <= static_cast(rows.size())) { const int cmd = resolveBarCommandId(rows[static_cast(chosen - 1)]); if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); } } // Shows the move/copy menu for the current selection (the SOURCE is the focused // region's bank). Lists every OTHER bank as a move destination, then the same list // as a copy destination. Move is the default (listed first); copy is secondary. void showSelectionMenu(int screenX, int screenY) { const std::vector sel = focusedSelectionIds(); if (sel.empty()) return; const std::string srcId = bankIdForRegion(g_panel.focusedRegion); // Destinations: pool + named banks, excluding the source. Ordinal order. struct Dest { std::string id; std::string name; }; std::vector dests; if (srcId != std::string(kPoolBankId)) dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); for (const Bank* bk : namedBanks()) if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); const std::string label = std::to_string(sel.size()) + (sel.size() == 1 ? " sample" : " samples"); HMENU menu = CreatePopupMenu(); // Move/copy blocks appear only when there is another bank to transfer to; Remove is // always offered (it needs no destination — it drops the entry from the source). if (!dests.empty()) { menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuMoveBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuCopyBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); } menuAppend(menu, kMenuRemove, ("Remove " + label + "...").c_str()); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); if (cmd == static_cast(kMenuRemove)) { removeSamples(sel, srcId); } else if (cmd >= static_cast(kMenuMoveBase) && cmd < static_cast(kMenuMoveBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); } else if (cmd >= static_cast(kMenuCopyBase) && cmd < static_cast(kMenuCopyBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); } } } // namespace reasampler::panel namespace reasampler { // GetUserInputs splits returned values on a separator defaulting to ',', so the // separator is overridden to \x1f (un-typeable) via the documented `separator=X` // trailing pseudo-caption — any printable name round-trips. bool promptBankName(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; // 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; } std::vector bankPanelSelectedSampleIds() { return panel::focusedSelectionIds(); } std::string bankPanelSelectedSourceBankId() { // The focused region's displayed bank is the move/copy source. Default to the // pool (a safe source) when nothing is selected / the panel never opened. if (panel::g_panel.selection.empty()) return std::string(kPoolBankId); const std::string id = panel::bankIdForRegion(panel::g_panel.focusedRegion); return id.empty() ? std::string(kPoolBankId) : id; } } // namespace reasampler