Merge Phase B: multi-bank system (pool + named banks) — B1 bank_book, B2 persist, B3 actions, B4 panel
This commit is contained in:
@@ -32,6 +32,7 @@ Key targets (see CMakeLists.txt for the full list):
|
||||
| `view_mode_model_tests` | executable | Pure unit tests for `view_mode_model` — no REAPER, no DAW. |
|
||||
| `view_tree_tests` | executable | Pure unit tests for `view_tree` — no REAPER, no DAW. |
|
||||
| `mode_switch_tests` | executable | Pure unit tests for `mode_switch` — no REAPER, no DAW. |
|
||||
| `bank_book_tests` | executable | Pure unit tests for `bank_book` — no REAPER, no DAW. |
|
||||
| `wav_trim_tests` | executable | Pure unit tests for `wav_trim` — no REAPER, no DAW. |
|
||||
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
|
||||
|
||||
@@ -55,6 +56,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. Mirror of `bank_model` for the Design View phase.
|
||||
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell; no REAPER types at the boundary.
|
||||
- `mode_switch` — REAPER-free segment layout + hit-test math for the bank_panel's Design View mode switch; divides a header rectangle into N equal segments and hit-tests a point to a segment. Mirror of `bank_grid`.
|
||||
- `bank_book` — multi-bank registry (Phase B): an ordered set of banks (pool seeded as bank-zero + named banks), each wrapping a `BankIndex`. Owns create/rename/reorder/delete of named banks, pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model, active-bank id, index-only move/copy of a sample between banks, JSON round-trip + legacy-`bank_index`→pool migration. Wraps `BankIndex` (bank_model untouched; no `bankId` on `Sample`).
|
||||
- `wav_trim` — 32-bit-float WAV parse + header-aware truncate plan (RIFF/data size rewrite) for the realtime tail's PCM decay-scan trim (T2). Rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias.
|
||||
|
||||
**REAPER-facing shells:**
|
||||
|
||||
+34
-1
@@ -55,6 +55,17 @@ target_include_directories(bank_grid PUBLIC src)
|
||||
add_library(mode_switch STATIC src/mode_switch.cpp)
|
||||
target_include_directories(mode_switch PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2c'') Pure tab_strip layout — NO REAPER, NO SWELL. The named-banks tab-strip
|
||||
# geometry (B4): strip rect + N tabs at a fixed tab width + scroll offset ->
|
||||
# per-tab rects (overflow-clipped), overflow chevron reservation + maxScroll,
|
||||
# and point -> tab / chevron hit-test. Split out so the strip's layout +
|
||||
# overflow/scroll math is unit-tested outside the DAW; the bank_panel region
|
||||
# that draws it and routes clicks is DAW-verified. Mirror of mode_switch.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(tab_strip STATIC src/tab_strip.cpp)
|
||||
target_include_directories(tab_strip PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2d) Pure view_mode_model library — NO REAPER, NO SWELL. The Design View heart
|
||||
# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware
|
||||
@@ -134,6 +145,18 @@ add_library(tail_control STATIC src/tail_control.cpp)
|
||||
target_include_directories(tail_control PUBLIC src)
|
||||
target_link_libraries(tail_control PUBLIC render_settings)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart
|
||||
# (Phase B1): an ordered registry of banks (pool seeded as bank-zero + named
|
||||
# banks), each wrapping a BankIndex; create/rename/reorder/delete named banks,
|
||||
# pool privileges enforced in-model, active-bank id, index-only move/copy of a
|
||||
# sample between banks, JSON round-trip + legacy-bank_index→pool migration.
|
||||
# Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched).
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(bank_book STATIC src/bank_book.cpp)
|
||||
target_include_directories(bank_book PUBLIC src)
|
||||
target_link_libraries(bank_book PUBLIC bank_model)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
|
||||
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
|
||||
@@ -184,6 +207,10 @@ add_executable(mode_switch_tests tests/test_mode_switch.cpp)
|
||||
target_link_libraries(mode_switch_tests PRIVATE mode_switch)
|
||||
add_test(NAME mode_switch_tests COMMAND mode_switch_tests)
|
||||
|
||||
add_executable(tab_strip_tests tests/test_tab_strip.cpp)
|
||||
target_link_libraries(tab_strip_tests PRIVATE tab_strip)
|
||||
add_test(NAME tab_strip_tests COMMAND tab_strip_tests)
|
||||
|
||||
add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
|
||||
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
||||
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
||||
@@ -216,6 +243,10 @@ add_executable(realtime_record_tests tests/test_realtime_record.cpp)
|
||||
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
|
||||
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
|
||||
|
||||
add_executable(bank_book_tests tests/test_bank_book.cpp)
|
||||
target_link_libraries(bank_book_tests PRIVATE bank_book)
|
||||
add_test(NAME bank_book_tests COMMAND bank_book_tests)
|
||||
|
||||
add_executable(wav_trim_tests tests/test_wav_trim.cpp)
|
||||
target_link_libraries(wav_trim_tests PRIVATE wav_trim)
|
||||
add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
|
||||
@@ -242,6 +273,7 @@ add_library(reaper_reasampler MODULE
|
||||
src/persist.cpp
|
||||
src/bank_panel.cpp
|
||||
src/mode_switch.cpp
|
||||
src/tab_strip.cpp
|
||||
src/insert.cpp
|
||||
src/insert_plan.cpp
|
||||
${LICE_SRC}
|
||||
@@ -253,8 +285,9 @@ add_library(reaper_reasampler MODULE
|
||||
src/lane_keys.cpp
|
||||
src/item_read.cpp
|
||||
src/actions.cpp
|
||||
src/bank_book.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record wav_trim)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
+10
-2
@@ -547,7 +547,13 @@ arrange; the only change is *which* index the entry lands in.
|
||||
display name, ordinal, BankIndex }`. **`BankIndex` is untouched** — the multi-bank
|
||||
layer wraps it, it does not modify it (additive; no `bank-id` field on `Sample`).
|
||||
Bank id is the stable key (GUID-style, minted on bank create); display name and
|
||||
ordinal are mutable (rename / reorder). The pool is the first, seeded, fixed-id
|
||||
ordinal are mutable (rename / reorder). **Display names are unique**, enforced in the
|
||||
pure model on create and rename: `createBank` / `renameBank` reject a name that
|
||||
duplicates an existing bank's (renaming a bank to its own current name is a no-op
|
||||
success). The comparison is **trimmed + case-insensitive (ASCII)**, so "Drums",
|
||||
"drums", and " Drums " cannot coexist; the pool's reserved name "Pool" is protected
|
||||
by the same check. Uniqueness makes by-name resolution in the action shell
|
||||
unambiguous by construction. The pool is the first, seeded, fixed-id
|
||||
member. `bank_book` is the mirror of `bank_model` and `view_mode_model`: pure, no
|
||||
REAPER types, unit-tested outside the DAW, JSON round-trip.
|
||||
- **Active bank lives in the model, routes through the capture path.** `bank_book`
|
||||
@@ -637,7 +643,9 @@ Pure (no REAPER types, unit-tested — the mirror of `bank_model` / `view_mode_m
|
||||
- `bank_book` — ordered bank registry (`{ bank id, display name, ordinal,
|
||||
BankIndex }`); pool seeded with fixed id + name; create / rename / reorder /
|
||||
delete named banks (pool-privilege rules enforced here: reject delete/rename of
|
||||
pool; delete drops member index entries); **evacuate** a bank (move every member to
|
||||
pool; delete drops member index entries; **display names unique** — create/rename
|
||||
reject a name that duplicates another bank's, trimmed + case-insensitive, "Pool"
|
||||
protected); **evacuate** a bank (move every member to
|
||||
the pool, index-only, destination-collapse observed; pool cannot be evacuated);
|
||||
active-bank id (get/set, defaults to pool); **move** and **copy** a sample between
|
||||
banks (index-only, destination-collapse observed); query a bank's index; JSON
|
||||
|
||||
@@ -90,9 +90,12 @@ reasons:
|
||||
|
||||
So: `bank_book` is an ordered registry of `{ bank id, display name, ordinal,
|
||||
BankIndex }`, pool seeded as bank-zero. Bank id is the stable key (minted GUID-style
|
||||
on create); name and ordinal are mutable. `BankIndex` is untouched. This is the
|
||||
defer-the-feature, design-the-seam principle: the seam is a container above the
|
||||
tested core, not a modification of it.
|
||||
on create); name and ordinal are mutable. Display names are **unique** — two banks
|
||||
cannot share a name (compared trimmed + case-insensitively, so "Drums" and "drums"
|
||||
are the same name), enforced in the model on create and rename; the pool's "Pool" is
|
||||
reserved by the same rule. `BankIndex` is untouched. This is the defer-the-feature,
|
||||
design-the-seam principle: the seam is a container above the tested core, not a
|
||||
modification of it.
|
||||
|
||||
---
|
||||
|
||||
@@ -349,7 +352,9 @@ Mirrors the capture and Design View pillars exactly.
|
||||
- Ordered bank registry: `{ bank id, display name, ordinal, BankIndex }`; pool
|
||||
seeded with fixed id + fixed name.
|
||||
- Create / rename / reorder / delete named banks; pool-privilege rules enforced
|
||||
here (reject delete-pool, reject rename-pool, never zero banks).
|
||||
here (reject delete-pool, reject rename-pool, never zero banks). Display names are
|
||||
unique — create/rename reject a name already used by another bank (trimmed +
|
||||
case-insensitive; the pool's "Pool" is protected).
|
||||
- Active-bank id (get/set, defaults to pool); resolve the active bank's `BankIndex`.
|
||||
- Move / copy a sample between banks — index-only, destination collapse-by-hash
|
||||
observed, move removes the source entry.
|
||||
|
||||
+389
-1
@@ -24,9 +24,11 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
|
||||
#include "bank_panel.h" // selection seam + full-height toggles (B3/B4)
|
||||
#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
|
||||
#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate
|
||||
#include "persist.h" // ReaSamplerSession (owns view() model)
|
||||
#include "persist.h" // ReaSamplerSession (owns book() + view() model)
|
||||
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
|
||||
#include "view.h" // applyMode + mintManagedLanes (D2 shell)
|
||||
#include "view_mode_model.h"
|
||||
@@ -43,6 +45,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
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
@@ -385,4 +391,386 @@ 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), 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.
|
||||
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.
|
||||
//
|
||||
// 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<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());
|
||||
const std::string captions = std::string(caption) + ",separator=\x1f";
|
||||
if (!GetUserInputs(title, 1, captions.c_str(), 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. 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;
|
||||
}
|
||||
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)) {
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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 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<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 bank the selection lives in (the focused region's displayed bank).
|
||||
// Pass ids by value — no BankIndex& is cached across the loop's mutations.
|
||||
const std::string srcId = bankPanelSelectedSourceBankId();
|
||||
if (srcId == destId) {
|
||||
ShowConsoleMsg("ReaSampler: source and destination are the same bank.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -39,4 +39,31 @@ bool designViewHandleCommand(int command);
|
||||
// '-'-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);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
#include "bank_book.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
// bank_book implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained, matching the house style of bank_model
|
||||
// and view_mode_model (brief: keep the pure core dependency-free — no third-party
|
||||
// JSON lib). The book blob nests one bank object per bank, each carrying that
|
||||
// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize),
|
||||
// so per-bank sample serialization stays owned by bank_model and is not duplicated
|
||||
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
|
||||
// raw "index" member whose value is the BankIndex blob verbatim; the parser splits
|
||||
// the book envelope, then hands each nested index blob straight to
|
||||
// BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankBook — construction + bank lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
BankBook::BankBook() {
|
||||
Bank pool;
|
||||
pool.id = kPoolBankId;
|
||||
pool.displayName = kPoolBankName;
|
||||
pool.ordinal = 0;
|
||||
banks_.push_back(std::move(pool));
|
||||
activeBankId_ = kPoolBankId;
|
||||
}
|
||||
|
||||
Bank* BankBook::bank(const std::string& id) {
|
||||
for (auto& b : banks_)
|
||||
if (b.id == id) return &b;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const Bank* BankBook::bank(const std::string& id) const {
|
||||
for (const auto& b : banks_)
|
||||
if (b.id == id) return &b;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
BankIndex* BankBook::index(const std::string& id) {
|
||||
Bank* b = bank(id);
|
||||
return b ? &b->index : nullptr;
|
||||
}
|
||||
|
||||
const BankIndex* BankBook::index(const std::string& id) const {
|
||||
const Bank* b = bank(id);
|
||||
return b ? &b->index : nullptr;
|
||||
}
|
||||
|
||||
Bank& BankBook::pool() {
|
||||
// The pool is seeded on construction and is un-deletable, so it always exists.
|
||||
return *bank(kPoolBankId);
|
||||
}
|
||||
|
||||
const Bank& BankBook::pool() const {
|
||||
return *bank(kPoolBankId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordinal normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BankBook::normalizeOrdinals() {
|
||||
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
|
||||
// contiguous 0..N-1. Stability preserves the caller's relative order among banks
|
||||
// that share (or, after a reorder shuffle, tie on) an ordinal.
|
||||
std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) {
|
||||
if (a.isPool() != b.isPool()) return a.isPool(); // pool always first
|
||||
return a.ordinal < b.ordinal;
|
||||
});
|
||||
for (std::size_t i = 0; i < banks_.size(); ++i)
|
||||
banks_[i].ordinal = static_cast<int>(i);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
||||
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
|
||||
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
|
||||
// facility and must not grow one; bank names are short user labels, not full Unicode
|
||||
// case-folding candidates.
|
||||
std::string nameKey(const std::string& s) {
|
||||
std::size_t b = 0, e = s.size();
|
||||
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
||||
while (b < e && isWs(s[b])) ++b;
|
||||
while (e > b && isWs(s[e - 1])) --e;
|
||||
std::string out;
|
||||
out.reserve(e - b);
|
||||
for (std::size_t i = b; i < e; ++i) {
|
||||
char c = s[i];
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The
|
||||
// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name.
|
||||
bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const {
|
||||
const std::string key = nameKey(name);
|
||||
for (const auto& b : banks_)
|
||||
if (b.id != exceptId && nameKey(b.displayName) == key) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bank lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
|
||||
if (id.empty()) return false; // ids key the registry
|
||||
if (id == kPoolBankId) return false; // reserved pool id
|
||||
if (bank(id) != nullptr) return false; // duplicate id
|
||||
// Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a
|
||||
// reserved name and is caught here like any other collision.
|
||||
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
|
||||
|
||||
Bank b;
|
||||
b.id = id;
|
||||
b.displayName = displayName;
|
||||
b.ordinal = static_cast<int>(banks_.size()); // append; normalize compacts it
|
||||
banks_.push_back(std::move(b));
|
||||
normalizeOrdinals();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
|
||||
if (id == kPoolBankId) return false; // pool is un-renamable
|
||||
Bank* b = bank(id);
|
||||
if (b == nullptr) return false;
|
||||
// Reject a name already used by a DIFFERENT bank. Renaming a bank to its own
|
||||
// current name (or a case/space variant of it) is a no-op success, not a
|
||||
// rejection — exceptId=id excludes the bank itself from the collision scan.
|
||||
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
|
||||
b->displayName = displayName;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankBook::deleteBank(const std::string& id) {
|
||||
if (id == kPoolBankId) return false; // pool is un-deletable
|
||||
auto it = std::find_if(banks_.begin(), banks_.end(),
|
||||
[&](const Bank& b) { return b.id == id; });
|
||||
if (it == banks_.end()) return false;
|
||||
|
||||
banks_.erase(it);
|
||||
// If the active bank was the one deleted, fall back to the pool (invariant: the
|
||||
// active id always names a live bank).
|
||||
if (activeBankId_ == id) activeBankId_ = kPoolBankId;
|
||||
normalizeOrdinals();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankBook::reorderBank(const std::string& id, int newOrdinal) {
|
||||
if (id == kPoolBankId) return false; // pool is pinned at ordinal 0
|
||||
if (bank(id) == nullptr) return false;
|
||||
|
||||
// Work on the named banks as an ordered list (banks_ is already ordinal-sorted
|
||||
// with the pool first, so named banks are banks_[1..]). Pull the target out and
|
||||
// re-insert it at the requested position, clamped into the named-bank range
|
||||
// [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct.
|
||||
std::vector<Bank> named;
|
||||
named.reserve(banks_.size());
|
||||
for (auto& b : banks_)
|
||||
if (!b.isPool()) named.push_back(std::move(b));
|
||||
|
||||
auto it = std::find_if(named.begin(), named.end(),
|
||||
[&](const Bank& b) { return b.id == id; });
|
||||
Bank moved = std::move(*it);
|
||||
named.erase(it);
|
||||
|
||||
// Named ordinals are 1..N; convert to a 0-based insertion index into `named`.
|
||||
const int hi = static_cast<int>(named.size()); // insert-at range is [0..size]
|
||||
int insertAt = std::max(0, std::min(newOrdinal - 1, hi));
|
||||
named.insert(named.begin() + insertAt, std::move(moved));
|
||||
|
||||
// Rebuild banks_: pool first, then the reordered named banks. Assign ordinals
|
||||
// directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys
|
||||
// on the (now stale) ordinals and would undo the reinsertion order.
|
||||
std::vector<Bank> rebuilt;
|
||||
rebuilt.reserve(named.size() + 1);
|
||||
rebuilt.push_back(std::move(pool()));
|
||||
for (auto& b : named) rebuilt.push_back(std::move(b));
|
||||
banks_ = std::move(rebuilt);
|
||||
for (std::size_t i = 0; i < banks_.size(); ++i)
|
||||
banks_[i].ordinal = static_cast<int>(i);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankBook::evacuate(const std::string& id) {
|
||||
if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target)
|
||||
Bank* src = bank(id);
|
||||
if (src == nullptr) return false;
|
||||
|
||||
// Move every member into the pool, index-only, observing destination collapse.
|
||||
// Snapshot the members first, then clear the source — BankIndex has no bulk move,
|
||||
// and adding into the pool must not alias the vector we are draining.
|
||||
BankIndex& poolIndex = pool().index;
|
||||
const std::vector<Sample> members = src->index.all(); // copy
|
||||
for (const auto& s : members)
|
||||
poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash
|
||||
src->index = BankIndex{}; // leave the evacuated bank empty
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bank
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool BankBook::setActiveBank(const std::string& id) {
|
||||
if (bank(id) == nullptr) return false; // unknown id never corrupts state
|
||||
activeBankId_ = id;
|
||||
return true;
|
||||
}
|
||||
|
||||
BankIndex& BankBook::activeIndex() {
|
||||
// activeBankId_ always names a live bank; it falls back to the pool on delete.
|
||||
return bank(activeBankId_)->index;
|
||||
}
|
||||
|
||||
const BankIndex& BankBook::activeIndex() const {
|
||||
return bank(activeBankId_)->index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample movement (index-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for
|
||||
// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes
|
||||
// (absolute path / empty id) cannot occur here: the sample already passed add() on
|
||||
// the source side, so its path and id are already valid.
|
||||
TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) {
|
||||
return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransferResult BankBook::moveSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId) {
|
||||
Bank* from = bank(fromBankId);
|
||||
Bank* to = bank(toBankId);
|
||||
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
|
||||
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
|
||||
|
||||
const Sample* s = from->index.query(sampleId);
|
||||
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
|
||||
|
||||
// Copy the sample out before removing it: query returns a pointer into the
|
||||
// source vector that remove() invalidates.
|
||||
const Sample moved = *s;
|
||||
from->index.remove(sampleId); // source loses the entry unconditionally on a move
|
||||
return applyDestAdd(to->index, moved, TransferResult::Moved);
|
||||
}
|
||||
|
||||
TransferResult BankBook::copySample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId) {
|
||||
Bank* from = bank(fromBankId);
|
||||
Bank* to = bank(toBankId);
|
||||
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
|
||||
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
|
||||
|
||||
const Sample* s = from->index.query(sampleId);
|
||||
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
|
||||
|
||||
const Sample copy = *s; // source entry is left intact
|
||||
return applyDestAdd(to->index, copy, TransferResult::Copied);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — writer
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
|
||||
out += buf;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
out += '"';
|
||||
}
|
||||
|
||||
std::string intToStr(int v) {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "%d", v);
|
||||
return buf;
|
||||
}
|
||||
|
||||
class ObjWriter {
|
||||
public:
|
||||
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
|
||||
~ObjWriter() { out_ += '}'; }
|
||||
|
||||
void keyRaw(const char* key, const std::string& rawValue) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
out_ += rawValue;
|
||||
}
|
||||
void keyStr(const char* key, const std::string& value) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
writeEscaped(out_, value);
|
||||
}
|
||||
void keyBegin(const char* key) {
|
||||
sep();
|
||||
writeEscaped(out_, key);
|
||||
out_ += ':';
|
||||
}
|
||||
|
||||
private:
|
||||
void sep() { if (first_) first_ = false; else out_ += ','; }
|
||||
std::string& out_;
|
||||
bool first_ = true;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string BankBook::serialize() const {
|
||||
std::string out;
|
||||
{
|
||||
ObjWriter root(out);
|
||||
root.keyRaw("version", intToStr(1));
|
||||
root.keyStr("activeBank", activeBankId_);
|
||||
|
||||
// banks: array of { id, displayName, ordinal, index: <BankIndex blob> }.
|
||||
// The pool rides in as bank-zero, persisted identically to any named bank.
|
||||
root.keyBegin("banks");
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < banks_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
ObjWriter b(out);
|
||||
b.keyStr("id", banks_[i].id);
|
||||
b.keyStr("displayName", banks_[i].displayName);
|
||||
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
|
||||
// The nested index is bank_model's own JSON, emitted verbatim so the
|
||||
// per-sample shape stays owned by BankIndex::serialize (not duplicated).
|
||||
b.keyRaw("index", banks_[i].index.serialize());
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
|
||||
// Parses a book blob into a bank set + active id. On success fills the out-params
|
||||
// and returns true. Distinguishes the legacy shape (a bare bank_index object: has
|
||||
// "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a
|
||||
// single pool bank carrying the migrated index and an empty active id (⇒ pool). The
|
||||
// member deserialize() adopts the result (ordinal normalize + active resolve).
|
||||
bool parseBook(std::vector<Bank>& banks, std::string& activeBank);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseString(std::string& out);
|
||||
bool parseInt(int& out);
|
||||
bool parseKey(std::string& key);
|
||||
bool skipValue();
|
||||
// Captures the raw source text of one JSON value (object / array / string /
|
||||
// scalar) verbatim, so a nested BankIndex blob can be handed to its own parser.
|
||||
bool captureValue(std::string& raw);
|
||||
|
||||
bool parseBank(Bank& out);
|
||||
};
|
||||
|
||||
bool Parser::parseString(std::string& out) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != '"') return false;
|
||||
++pos_;
|
||||
out.clear();
|
||||
while (!eof()) {
|
||||
char c = s_[pos_++];
|
||||
if (c == '"') return true;
|
||||
if (c == '\\') {
|
||||
if (eof()) return false;
|
||||
char e = s_[pos_++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
char h = s_[pos_++];
|
||||
cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
||||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
unsigned int hi = 0;
|
||||
if (!readHex4(hi)) return false;
|
||||
unsigned int codePoint = hi;
|
||||
if (hi >= 0xD800 && hi <= 0xDBFF) {
|
||||
if (pos_ + 6 > s_.size()) return false;
|
||||
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
|
||||
pos_ += 2;
|
||||
unsigned int lo = 0;
|
||||
if (!readHex4(lo)) return false;
|
||||
if (lo < 0xDC00 || lo > 0xDFFF) return false;
|
||||
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
||||
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
|
||||
return false; // unpaired low surrogate
|
||||
}
|
||||
if (codePoint <= 0x7F) {
|
||||
out += static_cast<char>(codePoint);
|
||||
} else if (codePoint <= 0x7FF) {
|
||||
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else if (codePoint <= 0xFFFF) {
|
||||
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return false; // unterminated
|
||||
}
|
||||
|
||||
bool Parser::parseInt(int& out) {
|
||||
skipWs();
|
||||
std::size_t start = pos_;
|
||||
if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_;
|
||||
std::size_t digitsStart = pos_;
|
||||
while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_;
|
||||
if (pos_ == digitsStart) return false; // no digits
|
||||
long v = 0;
|
||||
try {
|
||||
v = std::stol(s_.substr(start, pos_ - start));
|
||||
} catch (...) {
|
||||
return false; // out of long range → malformed
|
||||
}
|
||||
if (v < INT_MIN || v > INT_MAX) return false;
|
||||
out = static_cast<int>(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseKey(std::string& key) {
|
||||
if (!parseString(key)) return false;
|
||||
if (!consume(':')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::skipValue() {
|
||||
std::string raw;
|
||||
return captureValue(raw);
|
||||
}
|
||||
|
||||
// Records the raw source span of one JSON value starting at the current position
|
||||
// (after whitespace) so it can be re-parsed by a nested parser. Handles nested
|
||||
// objects/arrays with string-aware brace matching (braces inside strings ignored).
|
||||
bool Parser::captureValue(std::string& raw) {
|
||||
skipWs();
|
||||
if (eof()) return false;
|
||||
std::size_t start = pos_;
|
||||
char c = s_[pos_];
|
||||
if (c == '"') {
|
||||
std::string tmp;
|
||||
if (!parseString(tmp)) return false;
|
||||
raw.assign(s_, start, pos_ - start);
|
||||
return true;
|
||||
}
|
||||
if (c == '{' || c == '[') {
|
||||
char open = c, close = (c == '{') ? '}' : ']';
|
||||
++pos_;
|
||||
int depth = 1;
|
||||
while (!eof() && depth > 0) {
|
||||
char d = s_[pos_];
|
||||
if (d == '"') {
|
||||
std::string tmp;
|
||||
if (!parseString(tmp)) return false; // advances past the string
|
||||
continue;
|
||||
}
|
||||
if (d == open) ++depth;
|
||||
else if (d == close) --depth;
|
||||
++pos_;
|
||||
}
|
||||
if (depth != 0) return false;
|
||||
raw.assign(s_, start, pos_ - start);
|
||||
return true;
|
||||
}
|
||||
// bare scalar (number / true / false / null)
|
||||
while (!eof()) {
|
||||
char d = s_[pos_];
|
||||
if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' ||
|
||||
d == '\n' || d == '\r')
|
||||
break;
|
||||
++pos_;
|
||||
}
|
||||
if (pos_ == start) return false;
|
||||
raw.assign(s_, start, pos_ - start);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseBank(Bank& b) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return false; // a bank object must at least carry an id
|
||||
|
||||
bool haveId = false;
|
||||
bool haveIndex = false;
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
|
||||
if (key == "id") {
|
||||
if (!parseString(b.id)) return false;
|
||||
haveId = true;
|
||||
} else if (key == "displayName") {
|
||||
if (!parseString(b.displayName)) return false;
|
||||
} else if (key == "ordinal") {
|
||||
if (!parseInt(b.ordinal)) return false;
|
||||
} else if (key == "index") {
|
||||
std::string raw;
|
||||
if (!captureValue(raw)) return false;
|
||||
auto idx = BankIndex::deserialize(raw);
|
||||
if (!idx) return false; // a malformed nested index fails the whole parse
|
||||
b.index = std::move(*idx);
|
||||
haveIndex = true;
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
} while (consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
if (!haveId || b.id.empty()) return false; // id keys the registry
|
||||
if (!haveIndex) return false; // every bank persists its index
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
|
||||
banks.clear();
|
||||
activeBank.clear();
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return false; // an empty object is neither shape → malformed
|
||||
|
||||
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
|
||||
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
|
||||
std::vector<Bank> parsedBanks;
|
||||
bool sawBanks = false;
|
||||
bool sawSamples = false;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
if (!parseKey(key)) return false;
|
||||
|
||||
if (key == "banks") {
|
||||
sawBanks = true;
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (!consume(']')) {
|
||||
do {
|
||||
Bank b;
|
||||
if (!parseBank(b)) return false;
|
||||
parsedBanks.push_back(std::move(b));
|
||||
} while (consume(','));
|
||||
if (!consume(']')) return false;
|
||||
}
|
||||
} else if (key == "activeBank") {
|
||||
if (!parseString(activeBank)) return false;
|
||||
} else if (key == "samples") {
|
||||
// Legacy marker. The legacy index is re-parsed from the whole input below
|
||||
// (BankIndex::deserialize owns that shape); here we only skip the value to
|
||||
// keep the scan well-formed and note that we saw it.
|
||||
sawSamples = true;
|
||||
if (!skipValue()) return false;
|
||||
} else {
|
||||
if (!skipValue()) return false; // version, or unknown
|
||||
}
|
||||
} while (consume(','));
|
||||
|
||||
if (!consume('}')) return false;
|
||||
skipWs();
|
||||
if (!eof()) return false; // trailing garbage
|
||||
|
||||
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
|
||||
if (!sawBanks) {
|
||||
if (!sawSamples) return false; // neither shape's marker → malformed
|
||||
auto legacy = BankIndex::deserialize(s_);
|
||||
if (!legacy) return false;
|
||||
Bank pool;
|
||||
pool.id = kPoolBankId;
|
||||
pool.displayName = kPoolBankName;
|
||||
pool.ordinal = 0;
|
||||
pool.index = std::move(*legacy);
|
||||
banks.push_back(std::move(pool)); // { pool } with zero named banks
|
||||
activeBank.clear(); // ⇒ pool (default) after adoption
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
|
||||
// The pool must be present as bank-zero (serialize always emits it). Reject a
|
||||
// book blob that omits it rather than silently re-seeding — a book without its
|
||||
// pool is malformed, not a legacy blob.
|
||||
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
|
||||
[](const Bank& b) { return b.isPool(); });
|
||||
if (!hasPool) return false;
|
||||
|
||||
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
|
||||
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
|
||||
if (parsedBanks[i].id == parsedBanks[j].id) return false;
|
||||
|
||||
// Force the pool's fixed display name — it is not user-mutable, so we do not
|
||||
// trust a persisted override for it (keeps kPoolBankName authoritative).
|
||||
for (auto& b : parsedBanks)
|
||||
if (b.isPool()) b.displayName = kPoolBankName;
|
||||
|
||||
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
|
||||
// The in-model create/rename path enforces unique display names under nameKey,
|
||||
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
|
||||
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
|
||||
// would degrade the user's entire library to empty, so instead we AUTO-
|
||||
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
|
||||
// the first time a folded key repeats, suffix that bank's display name (" 2",
|
||||
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
|
||||
// bank to carry a key keeps its name verbatim; only subsequent collisions are
|
||||
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
|
||||
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
|
||||
// is disambiguated away from it, never the reverse.
|
||||
{
|
||||
std::vector<std::string> seenKeys;
|
||||
seenKeys.reserve(parsedBanks.size());
|
||||
for (auto& b : parsedBanks) {
|
||||
if (b.isPool()) { // pool's name is fixed; reserve its key
|
||||
seenKeys.push_back(nameKey(b.displayName));
|
||||
continue;
|
||||
}
|
||||
const auto taken = [&](const std::string& k) {
|
||||
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
|
||||
};
|
||||
std::string key = nameKey(b.displayName);
|
||||
if (taken(key)) {
|
||||
// Suffix with an ascending integer until the folded key is free. Guard
|
||||
// against a pathological blob whose base name already ends in a number
|
||||
// by folding the candidate each attempt (nameKey normalizes it).
|
||||
const std::string base = b.displayName;
|
||||
for (int n = 2;; ++n) {
|
||||
const std::string candidate = base + " " + std::to_string(n);
|
||||
const std::string candKey = nameKey(candidate);
|
||||
if (!taken(candKey)) {
|
||||
b.displayName = candidate;
|
||||
key = candKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
seenKeys.push_back(key);
|
||||
}
|
||||
}
|
||||
|
||||
banks = std::move(parsedBanks);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
|
||||
banks_ = std::move(banks);
|
||||
normalizeOrdinals();
|
||||
// Resolve the active bank defensively: fall back to the pool if the persisted id
|
||||
// names no bank, so a corrupt active id never leaves a dangling capture target.
|
||||
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
|
||||
}
|
||||
|
||||
std::optional<BankBook> BankBook::deserialize(const std::string& json) {
|
||||
std::vector<Bank> banks;
|
||||
std::string activeBank;
|
||||
Parser p(json);
|
||||
if (!p.parseBook(banks, activeBank)) return std::nullopt;
|
||||
|
||||
BankBook book;
|
||||
book.adoptBanks(std::move(banks), activeBank);
|
||||
return book;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
||||
const std::string& currentBankId) {
|
||||
if (orderedBankIds.empty()) return {}; // nothing to cycle to
|
||||
for (std::size_t i = 0; i < orderedBankIds.size(); ++i) {
|
||||
if (orderedBankIds[i] == currentBankId)
|
||||
return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last
|
||||
}
|
||||
// Active id not in the list (stale/unknown) — jump to the first id as a sane
|
||||
// home rather than returning "" (matches nextModeId's fallback).
|
||||
return orderedBankIds.front();
|
||||
}
|
||||
|
||||
BankBook BankBook::loadFromPersisted(const std::string& banksJson,
|
||||
const std::string& legacyJson) {
|
||||
// Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is
|
||||
// an error, not an absence — degrade to an empty book rather than falling through
|
||||
// to a stale legacy key (which would resurrect superseded single-bank state).
|
||||
if (!banksJson.empty()) {
|
||||
auto book = deserialize(banksJson);
|
||||
return book ? std::move(*book) : BankBook{};
|
||||
}
|
||||
// Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration
|
||||
// (deserialize's parse-time legacy path promotes it into the pool). A malformed
|
||||
// legacy blob likewise degrades to empty.
|
||||
if (!legacyJson.empty()) {
|
||||
auto book = deserialize(legacyJson);
|
||||
return book ? std::move(*book) : BankBook{};
|
||||
}
|
||||
// Precedence 3: a brand-new / never-captured project — a fresh empty book.
|
||||
return BankBook{};
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
#pragma once
|
||||
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
|
||||
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
|
||||
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
|
||||
// the DAW" pattern as bank_model and view_mode_model.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
|
||||
// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are
|
||||
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
|
||||
// index-only (remove from source's BankIndex, add to destination's); files never
|
||||
// relocate — banks are logical groupings over one shared file pool.
|
||||
//
|
||||
// -- The pool (privileged, not special-cased) --------------------------------
|
||||
//
|
||||
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
|
||||
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
|
||||
// Semantically it is privileged, and the privileges are enforced HERE in the pure
|
||||
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
|
||||
// * always exists (seeded on construction; the book never reaches zero banks)
|
||||
// * un-deletable (deleteBank rejects the pool)
|
||||
// * un-renamable (renameBank rejects the pool)
|
||||
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
|
||||
// destination, not a source)
|
||||
//
|
||||
// -- Id minting is the CALLER'S job (design decision) ------------------------
|
||||
//
|
||||
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
|
||||
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
|
||||
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
|
||||
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
|
||||
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
|
||||
// testable. The model still enforces the invariants: non-empty, unique, not the
|
||||
// reserved pool id.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_model.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
|
||||
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
|
||||
inline constexpr const char* kPoolBankId = "pool";
|
||||
inline constexpr const char* kPoolBankName = "Pool";
|
||||
|
||||
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
|
||||
// own BankIndex. The pool is the bank whose id == kPoolBankId.
|
||||
struct Bank {
|
||||
std::string id; // stable, persisted; the pool's is kPoolBankId
|
||||
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
|
||||
int ordinal = 0; // display order; pool is 0, named banks 1..N
|
||||
BankIndex index; // this bank's samples
|
||||
|
||||
bool isPool() const { return id == kPoolBankId; }
|
||||
|
||||
bool operator==(const Bank& o) const {
|
||||
return id == o.id && displayName == o.displayName &&
|
||||
ordinal == o.ordinal && index == o.index;
|
||||
}
|
||||
};
|
||||
|
||||
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
|
||||
// reports what happened rather than silently mutating on a bad request.
|
||||
// - Moved / Copied: the sample was transferred to the destination as a new entry.
|
||||
// - Collapsed: the destination already held the hash; it collapsed onto the
|
||||
// existing entry (a no-op add on the destination side). For a
|
||||
// MOVE the source entry is STILL removed; for a COPY the source
|
||||
// entry is (as always) retained.
|
||||
// - RejectedUnknownBank: a source or destination id named no bank.
|
||||
// - RejectedSampleAbsent: the sample id was not in the source bank.
|
||||
// - RejectedSameBank: source and destination were the same bank (no-op).
|
||||
enum class TransferResult {
|
||||
Moved,
|
||||
Copied,
|
||||
Collapsed,
|
||||
RejectedUnknownBank,
|
||||
RejectedSampleAbsent,
|
||||
RejectedSameBank,
|
||||
};
|
||||
|
||||
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
||||
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
||||
// multi-bank phase — mirror of bank_model / view_mode_model.
|
||||
class BankBook {
|
||||
public:
|
||||
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
|
||||
// active bank = pool; zero named banks.
|
||||
|
||||
// -- Bank lifecycle ------------------------------------------------------
|
||||
|
||||
// Creates a named bank with the caller-supplied stable id and display name,
|
||||
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
|
||||
// a duplicate id, the reserved pool id, or a display name that duplicates an
|
||||
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
|
||||
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
|
||||
bool createBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
|
||||
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
|
||||
// createBank). Renaming a bank to its own current name is a no-op success.
|
||||
bool renameBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Deletes a NAMED bank, removing it (and its member index entries) from the
|
||||
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
|
||||
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
|
||||
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
|
||||
// deleted bank was active, the active bank falls back to the pool.
|
||||
bool deleteBank(const std::string& id);
|
||||
|
||||
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
|
||||
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
|
||||
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
|
||||
bool reorderBank(const std::string& id, int newOrdinal);
|
||||
|
||||
// Moves EVERY member of a named bank into the pool (index-only, observing the
|
||||
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
|
||||
// (false, no mutation) an unknown id or the pool (the pool is the destination,
|
||||
// never a source). Returns true on success even if the bank was already empty.
|
||||
bool evacuate(const std::string& id);
|
||||
|
||||
// -- Active bank ---------------------------------------------------------
|
||||
|
||||
// The active bank's id (the capture target). Defaults to the pool.
|
||||
const std::string& activeBankId() const { return activeBankId_; }
|
||||
|
||||
// Sets the active bank. Rejects (returns false, no change) an id that names no
|
||||
// bank — an invalid set never corrupts state.
|
||||
bool setActiveBank(const std::string& id);
|
||||
|
||||
// The active bank's BankIndex — the index the capture layer adds to. Always
|
||||
// valid (the active id always names a live bank; it falls back to the pool).
|
||||
BankIndex& activeIndex();
|
||||
const BankIndex& activeIndex() const;
|
||||
|
||||
// -- Sample movement (index-only; files never relocate) ------------------
|
||||
|
||||
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
|
||||
// source index and adds it to the destination (observing destination
|
||||
// collapse-by-hash). See TransferResult for the full outcome set.
|
||||
TransferResult moveSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
|
||||
// retained, the destination gains it (observing destination collapse-by-hash).
|
||||
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
|
||||
TransferResult copySample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// -- Query ---------------------------------------------------------------
|
||||
|
||||
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
||||
Bank* bank(const std::string& id);
|
||||
const Bank* bank(const std::string& id) const;
|
||||
|
||||
// The bank's BankIndex by id, or nullptr. Convenience over bank()->index.
|
||||
BankIndex* index(const std::string& id);
|
||||
const BankIndex* index(const std::string& id) const;
|
||||
|
||||
// The pool (always present). Never null.
|
||||
Bank& pool();
|
||||
const Bank& pool() const;
|
||||
|
||||
// All banks in ordinal order (pool first). The pool is always banks()[0].
|
||||
const std::vector<Bank>& banks() const { return banks_; }
|
||||
|
||||
std::size_t size() const { return banks_.size(); } // >= 1 (the pool)
|
||||
|
||||
bool operator==(const BankBook& o) const {
|
||||
return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
|
||||
}
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serializes the whole book to a JSON string (lossless round-trip): the pool
|
||||
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
|
||||
// id. deserialize(serialize(x)) == x.
|
||||
std::string serialize() const;
|
||||
|
||||
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
|
||||
//
|
||||
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
|
||||
// object with a "samples" array and no "banks" key) is promoted into the pool's
|
||||
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
|
||||
// After migration the book blob is authoritative (the caller persists the book
|
||||
// shape going forward; the legacy key is retired by the B2 shell).
|
||||
static std::optional<BankBook> deserialize(const std::string& json);
|
||||
|
||||
// Resolve a BankBook from the two persisted ext-state values a project may carry:
|
||||
// the authoritative `banks` blob and the retired-but-possibly-present legacy
|
||||
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
|
||||
// the load-source decision stays REAPER-free and unit-tested. Precedence:
|
||||
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
|
||||
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
|
||||
// blob is an error, not an absence; return an empty book so a stale legacy key
|
||||
// can never resurrect a superseded single-bank state over a broken book.
|
||||
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
|
||||
// 3. else (both absent/empty) -> a fresh empty book (pool only).
|
||||
// Never returns nullopt: an unloadable input degrades to the empty book (matching
|
||||
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
|
||||
// has one branchless install path.
|
||||
static BankBook loadFromPersisted(const std::string& banksJson,
|
||||
const std::string& legacyJson);
|
||||
|
||||
private:
|
||||
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
|
||||
std::string activeBankId_; // always names a live bank; defaults to pool
|
||||
|
||||
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
|
||||
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
|
||||
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
|
||||
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
|
||||
|
||||
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
|
||||
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
|
||||
// structural change (create / delete / reorder).
|
||||
void normalizeOrdinals();
|
||||
|
||||
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
|
||||
// the active bank (falling back to the pool if the id names no bank). Used only
|
||||
// by deserialize; kept private so the public surface stays create/rename/etc.
|
||||
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
|
||||
};
|
||||
|
||||
// The next bank id to activate when cycling the active bank forward, in ordinal
|
||||
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
|
||||
// after the last returns the first (pool → named → … → pool). This is the pure
|
||||
// decision behind the "cycle active bank" action — the shell reads the book's
|
||||
// ordered bank ids + current active id, asks for the next, and activates it.
|
||||
// * empty list -> "" (nothing to cycle to)
|
||||
// * single id (pool-only) -> that id (a one-bank book stays put)
|
||||
// * currentBankId not present -> the first id (a sane home to jump to)
|
||||
// Exposed as a free function (not a BankBook member) so it is unit-testable against
|
||||
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
|
||||
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
||||
const std::string& currentBankId);
|
||||
|
||||
} // namespace reasampler
|
||||
+1131
-380
File diff suppressed because it is too large
Load Diff
@@ -43,8 +43,23 @@ bool bankPanelIsOpen();
|
||||
// Note: the panel's selection is cleared on a bank change (capture / project
|
||||
// load), so a returned id always names a sample present in the current bank at
|
||||
// the moment of the call; the caller still tolerates an absent id gracefully.
|
||||
//
|
||||
// Phase B4 (vertical split): the selection lives in whichever REGION the user last
|
||||
// interacted with (the pool grid on top or a named-bank grid below), which is NOT
|
||||
// necessarily the active/capture-target bank. The returned ids therefore name
|
||||
// samples in the FOCUSED region's displayed bank — the bank the user visibly
|
||||
// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those
|
||||
// ids belong to (the move/copy source).
|
||||
std::vector<std::string> bankPanelSelectedSampleIds();
|
||||
|
||||
// The bank id the current selection belongs to — the displayed bank of the region
|
||||
// the user last interacted with (pool region -> the pool id; named-banks region ->
|
||||
// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current
|
||||
// selection, and it is distinct from the active/capture-target bank (active ≠ shown).
|
||||
// Returns the pool id when nothing is selected or the panel has never opened (a safe
|
||||
// default source). READ of panel state only; no mutation.
|
||||
std::string bankPanelSelectedSourceBankId();
|
||||
|
||||
// Requests a repaint if the bank changed since the last paint (generation bump).
|
||||
// Cheap when nothing changed. Driven by the timer so a capture / project load is
|
||||
// reflected without the panel diffing the bank itself.
|
||||
@@ -72,6 +87,36 @@ void bankPanelNotifyProjectLoaded();
|
||||
// state only; the toggle is mutated by a click inside the panel, never here.
|
||||
TailSetting bankPanelTailSetting();
|
||||
|
||||
// The vertical-split full-height layout state (Phase B). The bank window splits
|
||||
// vertically — pool on top, named-banks region below — and two toggles collapse the
|
||||
// split: pool full-height (hide the named-banks region) and banks full-height (hide
|
||||
// the pool). The two are mutually exclusive with the default (both regions shown),
|
||||
// so one enum captures the whole state.
|
||||
//
|
||||
// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives
|
||||
// here beside the tail setting — the other session-level view-layout bit the panel
|
||||
// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not
|
||||
// project state, so it must not travel with the .rpp. In-memory for the extension's
|
||||
// lifetime; resets to Split on unload.
|
||||
enum class BankPanelFullHeight {
|
||||
Split, // default: pool region on top, named-banks region below
|
||||
PoolOnly, // pool full-height — named-banks region hidden
|
||||
BanksOnly, // banks full-height — pool region hidden
|
||||
};
|
||||
|
||||
// The current full-height layout state (default Split). READ by B4's panel to decide
|
||||
// which region(s) to draw. Safe before the panel has ever opened.
|
||||
BankPanelFullHeight bankPanelFullHeight();
|
||||
|
||||
// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from
|
||||
// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool
|
||||
// full-height" action. Requests a repaint so an open panel reflects the change.
|
||||
void bankPanelToggledPoolFullHeight();
|
||||
|
||||
// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle.
|
||||
// Bound to the "banks full-height" action. Requests a repaint.
|
||||
void bankPanelToggledBanksFullHeight();
|
||||
|
||||
// Tears the panel down on extension unload: destroys the window and releases any
|
||||
// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened.
|
||||
void bankPanelShutdown();
|
||||
|
||||
+7
-1
@@ -125,7 +125,13 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request)
|
||||
const std::string projectDir = currentProjectDir();
|
||||
if (projectDir.empty()) { result.status = InsertStatus::NoProject; return result; }
|
||||
|
||||
const BankIndex& bank = session->bank();
|
||||
// Resolve the id against the bank the SELECTION came from — under B4's vertical
|
||||
// split the selection may live in the pool or a shown named bank, which is NOT
|
||||
// necessarily the active/capture-target bank. Fall back to the active bank when
|
||||
// the source id names no bank (defensive).
|
||||
const std::string srcBankId = bankPanelSelectedSourceBankId();
|
||||
const BankIndex* srcIndex = session->book().index(srcBankId);
|
||||
const BankIndex& bank = srcIndex ? *srcIndex : session->bank();
|
||||
const Sample* sample = bank.query(id);
|
||||
if (!sample) { result.status = InsertStatus::NothingResolved; return result; }
|
||||
|
||||
|
||||
+22
-8
@@ -111,9 +111,10 @@ static int g_cmdCancelRealtime = 0;
|
||||
|
||||
// The persistence session (M4): owns the in-memory BankIndex and bridges it to
|
||||
// project ext state. A timer tick drives g_session.poll() to detect project
|
||||
// load / Save-As; capture adds Samples to g_session.bank(); after a capture we
|
||||
// serialize the bank back into the active project's ext state so it travels with
|
||||
// the .rpp. Replaces the M3 session-only g_bank.
|
||||
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
|
||||
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
|
||||
// the book back into the active project's ext state (the `banks` key) so it travels
|
||||
// with the .rpp. Replaces the M3 session-only g_bank.
|
||||
static reasampler::ReaSamplerSession g_session;
|
||||
|
||||
// --- M8 in-flight realtime capture (async, timer-driven) --------------------
|
||||
@@ -133,8 +134,9 @@ static reasampler::RealtimeCaptureHandle g_rtCapture;
|
||||
static ReaProject* g_rtCaptureProject = nullptr;
|
||||
|
||||
// Commit a finished realtime capture (a Done tick/abort with an Ok result): add the
|
||||
// Sample to the bank, persist + MarkProjectDirty, log. Shared by the tick-completion
|
||||
// path and the abort paths. On a non-Ok result, logs the failure only.
|
||||
// Sample to the ACTIVE bank (g_session.bank() resolves to book.activeIndex() — B2),
|
||||
// persist + MarkProjectDirty, log. Shared by the tick-completion path and the abort
|
||||
// paths. On a non-Ok result, logs the failure only.
|
||||
static void CommitRealtimeResult(const reasampler::CaptureResult& res)
|
||||
{
|
||||
if (res.status != reasampler::CaptureStatus::Ok)
|
||||
@@ -561,10 +563,12 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
|
||||
reasampler::AddResult added = g_session.bank().add(res.sample);
|
||||
// Persist the updated bank into the active project's ext state so the capture
|
||||
// survives Save / close+reopen (M4) and travels with the .rpp. saveToActiveProject
|
||||
// also calls MarkProjectDirty. Non-destructive: writes only our own ext-state key.
|
||||
// Persist the updated book into the active project's ext state (the `banks` key)
|
||||
// so the capture survives Save / close+reopen (M4) and travels with the .rpp.
|
||||
// saveToActiveProject also clears the retired legacy key and calls MarkProjectDirty.
|
||||
// Non-destructive: writes only our own ext-state keys.
|
||||
g_session.saveToActiveProject();
|
||||
|
||||
std::string log = "ReaSampler: " + res.message + "\n";
|
||||
@@ -738,6 +742,8 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
// Design View action family (D4). Claims only its own ids; returns false for the
|
||||
// rest so this hook keeps looking (per the contract).
|
||||
if (reasampler::designViewHandleCommand(command)) return true;
|
||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
||||
if (reasampler::bankHandleCommand(command)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -785,6 +791,8 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
// Tear down the Design View action family (D4) — mirror-unregisters each
|
||||
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime);
|
||||
g_rec->Register("-command_id",
|
||||
(void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE"));
|
||||
@@ -934,6 +942,12 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
// the hook so every id is minted first.
|
||||
reasampler::designViewRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
|
||||
// activate (cycle + pool), move/copy selected samples to a bank, and the two
|
||||
// full-height layout toggles. Shares g_session with the Design View family; routed
|
||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
||||
reasampler::bankRegisterActions(rec, &g_session);
|
||||
|
||||
// One hookcommand routes every ReaSampler action (spike + toggle + Design View).
|
||||
// Registered once, after all command ids are minted.
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
|
||||
+60
-32
@@ -5,11 +5,15 @@
|
||||
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||
// pointers; here they are extern (CLAUDE.md §contract).
|
||||
//
|
||||
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler", key
|
||||
// "bank_index". Ext state is stored INSIDE the .rpp, so the index travels with
|
||||
// the project automatically (CONTEXT.md §Persistence & paths). The only thing
|
||||
// that does NOT travel for free is the physical bank folder; on Save-As to a new
|
||||
// directory we relocate it so the index's relative paths still resolve.
|
||||
// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the
|
||||
// whole BankBook (pool as bank-zero + named banks) is written under key "banks"
|
||||
// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on
|
||||
// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre-
|
||||
// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so
|
||||
// the banks travel with the project automatically (CONTEXT.md §Persistence & paths).
|
||||
// The only thing that does NOT travel for free is the physical bank folder; on
|
||||
// Save-As to a new directory we relocate it so the indices' relative paths still
|
||||
// resolve.
|
||||
//
|
||||
// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism):
|
||||
// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active
|
||||
@@ -174,12 +178,22 @@ void ReaSamplerSession::saveToActiveProject() {
|
||||
if (!proj) return; // no active project — nothing to persist
|
||||
if (rppPath.empty()) return; // unsaved project — no .rpp to store into
|
||||
|
||||
const std::string json = bank_.serialize();
|
||||
// Phase B: the whole book (pool as bank-zero + named banks) is authoritative and
|
||||
// rides in the `banks` key.
|
||||
const std::string banksJson = book_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtIndexKey, json.c_str());
|
||||
kProjExtBanksKey, banksJson.c_str());
|
||||
|
||||
// Additive: the Design-View model rides alongside the bank in its own key.
|
||||
// Independent write — does not disturb the bank_index above.
|
||||
// Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty
|
||||
// value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This
|
||||
// realizes retirement concretely — after any save, a formerly-legacy project
|
||||
// carries `banks` and NO `bank_index`, and going forward the legacy key is never
|
||||
// written. Cheap and idempotent when the key is already absent.
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtIndexKey, "");
|
||||
|
||||
// Additive: the Design-View model rides alongside the banks in its own key.
|
||||
// Independent write — does not disturb the `banks` blob above.
|
||||
const std::string viewJson = view_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtViewKey, viewJson.c_str());
|
||||
@@ -255,32 +269,46 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||
|
||||
if (!proj) {
|
||||
bank_ = BankIndex{};
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
}
|
||||
const std::string json =
|
||||
getProjExtStateString(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtIndexKey);
|
||||
if (json.empty()) {
|
||||
// No stored index (new or never-captured project) — start empty.
|
||||
bank_ = BankIndex{};
|
||||
return;
|
||||
}
|
||||
std::optional<BankIndex> loaded = BankIndex::deserialize(json);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored bank index is malformed — ignoring.\n");
|
||||
bank_ = BankIndex{};
|
||||
return;
|
||||
}
|
||||
bank_ = std::move(*loaded);
|
||||
|
||||
// Project-relative resolution is a READ-time concern: the index stores only
|
||||
// relative paths (invariant), and consumers (M5 panel, M6 insert) resolve
|
||||
// each entry against the CURRENT project dir via resolveBankFile(projectDir,
|
||||
// relativePath). We do NOT rewrite the stored paths to absolute here — that
|
||||
// would break the relative-only invariant and the travel-with-.rpp property.
|
||||
// projectDir is threaded through for those consumers; nothing to do at load
|
||||
// time beyond replacing the in-memory bank.
|
||||
// Read both possible sources: the authoritative `banks` blob and the retired-but-
|
||||
// possibly-still-present legacy `bank_index`. The precedence + migration decision
|
||||
// (`banks` wins; else the legacy index migrates into the pool; else an empty book)
|
||||
// is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only
|
||||
// so a malformed `banks` blob can be warned on the console (single parse) — a corrupt
|
||||
// blob must read as "ignored", not silent loss, mirroring the prior malformed-index
|
||||
// warning. A malformed `banks` degrades to an empty book and does NOT fall back to
|
||||
// the stale legacy key (which would resurrect superseded single-bank state).
|
||||
const std::string banksJson =
|
||||
getProjExtStateString(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtBanksKey);
|
||||
if (!banksJson.empty()) {
|
||||
std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored banks are malformed — ignoring.\n");
|
||||
book_ = BankBook{};
|
||||
} else {
|
||||
book_ = std::move(*loaded);
|
||||
}
|
||||
} else {
|
||||
// No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool
|
||||
// by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or-
|
||||
// empty tail; passing "" for banksJson takes exactly that branch.
|
||||
const std::string legacyJson =
|
||||
getProjExtStateString(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtIndexKey);
|
||||
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
}
|
||||
|
||||
// Project-relative resolution is a READ-time concern: every BankIndex in the book
|
||||
// stores only relative paths (invariant, enforced per-bank at add()), and consumers
|
||||
// (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via
|
||||
// resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to
|
||||
// absolute here — that would break the relative-only invariant and travel-with-.rpp.
|
||||
// projectDir is threaded through for those consumers; nothing to do at load time
|
||||
// beyond replacing the in-memory book.
|
||||
(void)projectDir;
|
||||
}
|
||||
|
||||
|
||||
+43
-16
@@ -19,6 +19,7 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "bank_book.h"
|
||||
#include "bank_model.h"
|
||||
#include "tail_control.h"
|
||||
#include "view_mode_model.h"
|
||||
@@ -29,10 +30,21 @@ namespace reasampler {
|
||||
// shipped: changing it orphans every already-saved project's index.
|
||||
inline constexpr const char* kProjExtNamespace = "reasampler";
|
||||
|
||||
// The ext-state key the index JSON is stored under (one key holds the whole
|
||||
// serialized BankIndex). FOREVER-STABLE for the same reason.
|
||||
// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole
|
||||
// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save
|
||||
// the key is cleared (SetProjExtState with "" deletes it) and the book is written
|
||||
// under kProjExtBanksKey instead. It is still READ once, on load of a legacy
|
||||
// project, to migrate its single index into the pool (BankBook's parse-time
|
||||
// promotion). FOREVER-STABLE as a read key for that migration path.
|
||||
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
||||
|
||||
// The multi-bank ext-state key (Phase B): one key holds the whole serialized
|
||||
// BankBook — the pool folded in as bank-zero plus every named bank, each with its
|
||||
// own BankIndex, ordinals, and the active-bank id. AUTHORITATIVE going forward;
|
||||
// supersedes kProjExtIndexKey. FOREVER-STABLE once shipped: changing it orphans
|
||||
// every already-saved project's banks.
|
||||
inline constexpr const char* kProjExtBanksKey = "banks";
|
||||
|
||||
// The ext-state key the Design-View ViewModeModel JSON is stored under (one key
|
||||
// holds the whole serialized model: modes + membership + show-both + snapshots +
|
||||
// active mode). Distinct from kProjExtIndexKey — one namespace, two keys.
|
||||
@@ -53,8 +65,9 @@ inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
// it strands the identity of every already-saved project. See persist.cpp.
|
||||
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
|
||||
// Owns the session's BankIndex and drives persistence against the active REAPER
|
||||
// project. One instance lives for the extension's lifetime (main.cpp). It tracks
|
||||
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
||||
// against the active REAPER project. One instance lives for the extension's
|
||||
// lifetime (main.cpp). It tracks
|
||||
// the project identity it last saw so the timer tick can detect a project load
|
||||
// (a different project became active) and a Save-As (SAME project, path changed):
|
||||
//
|
||||
@@ -70,16 +83,28 @@ inline constexpr const char* kProjExtGuidKey = "project_guid";
|
||||
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
|
||||
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
|
||||
//
|
||||
// The bank itself is exposed for the capture/action layer to mutate; persist
|
||||
// The book itself is exposed for the capture/action layer to mutate; persist
|
||||
// only reads it on save and replaces it on load.
|
||||
class ReaSamplerSession {
|
||||
public:
|
||||
ReaSamplerSession() = default;
|
||||
|
||||
// The in-memory bank. The action/capture layer adds captures here; persist
|
||||
// serializes it on save and replaces it on project load.
|
||||
BankIndex& bank() { return bank_; }
|
||||
const BankIndex& bank() const { return bank_; }
|
||||
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
|
||||
// BankIndex, plus the active-bank id. The action layer (B3) creates / renames /
|
||||
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
|
||||
// persist serializes it under the `banks` key on save and replaces it on load.
|
||||
BankBook& book() { return book_; }
|
||||
const BankBook& book() const { return book_; }
|
||||
|
||||
// The capture add-target: the ACTIVE bank's BankIndex (defaults to the pool).
|
||||
// The capture path adds a captured Sample through this seam, so a capture lands
|
||||
// in whichever bank is active — the single behavioural change B2 wires in over
|
||||
// M7/M8 (the capture backends are untouched; only the target index moved). The
|
||||
// panel/insert readers that displayed the single index continue to read it here
|
||||
// unchanged; today it resolves to the pool (default active), matching prior
|
||||
// single-bank behaviour, until B3/B4 let the user switch the active bank.
|
||||
BankIndex& bank() { return book_.activeIndex(); }
|
||||
const BankIndex& bank() const { return book_.activeIndex(); }
|
||||
|
||||
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
||||
// toggle, snapshot); persist serializes it on save and replaces it on project
|
||||
@@ -97,9 +122,10 @@ public:
|
||||
TailSetting& tail() { return tail_; }
|
||||
const TailSetting& tail() const { return tail_; }
|
||||
|
||||
// Serialize the current bank to the active project's ext state (namespace
|
||||
// "reasampler"). Non-destructive beyond writing our own ext-state key. Safe
|
||||
// to call when there is no active/saved project (it no-ops).
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
// Safe to call when there is no active/saved project (it no-ops).
|
||||
void saveToActiveProject();
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
@@ -120,7 +146,7 @@ public:
|
||||
bool consumeLoadSignal();
|
||||
|
||||
private:
|
||||
BankIndex bank_;
|
||||
BankBook book_;
|
||||
|
||||
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
||||
// = Arrange; loadFromProject leaves this default when a project has no stored
|
||||
@@ -146,9 +172,10 @@ private:
|
||||
bool primed_ = false; // false until the first poll() observes state
|
||||
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
|
||||
|
||||
// Load the index from the given project's ext state and resolve bank paths
|
||||
// against projectDir. Replaces the in-memory bank. projectDir empty -> clears
|
||||
// the bank (unsaved project has no resolvable bank).
|
||||
// Load the book from the given project's ext state (the `banks` key, else the
|
||||
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
|
||||
// projectDir at read time. Replaces the in-memory book. projectDir empty -> the
|
||||
// book is reset to empty (unsaved project has no resolvable banks).
|
||||
void loadFromProject(void* proj, const std::string& projectDir);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// tab_strip — pure implementation. See tab_strip.h. NO REAPER / SWELL / vendor.
|
||||
|
||||
#include "tab_strip.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
(void)scrollOffset; // layout depends on geometry only, not the current offset
|
||||
TabStripLayout out;
|
||||
if (tabCount <= 0 || strip.width <= 0) {
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width > 0 ? strip.width : 0;
|
||||
return out; // nothing to lay out: track == strip, no overflow, no chevrons
|
||||
}
|
||||
|
||||
const int totalTabsWidth = tabCount * spec.tabWidth;
|
||||
if (totalTabsWidth <= strip.width) {
|
||||
// Everything fits: the whole strip is the track; no chevrons, no scroll.
|
||||
out.overflow = false;
|
||||
out.trackX = strip.x;
|
||||
out.trackWidth = strip.width;
|
||||
out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Overflow: reserve a chevron band at each end; the tabs live between them.
|
||||
out.overflow = true;
|
||||
out.leftChevron = true;
|
||||
out.rightChevron = true;
|
||||
out.trackX = strip.x + spec.chevronWidth;
|
||||
out.trackWidth = strip.width - 2 * spec.chevronWidth;
|
||||
if (out.trackWidth < 0) out.trackWidth = 0;
|
||||
// The tab run exceeds the track by this many pixels; the strip may scroll exactly
|
||||
// that far so the last tab's right edge reaches the track's right edge, no more.
|
||||
out.maxScroll = totalTabsWidth - out.trackWidth;
|
||||
if (out.maxScroll < 0) out.maxScroll = 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout) {
|
||||
if (desiredOffset < 0) return 0;
|
||||
if (desiredOffset > layout.maxScroll) return layout.maxScroll;
|
||||
return desiredOffset;
|
||||
}
|
||||
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
std::vector<TabRect> rects;
|
||||
if (tabCount <= 0 || strip.width <= 0) return rects;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
const int offset = layout.overflow ? clampTabScroll(scrollOffset, layout) : 0;
|
||||
const int trackLeft = layout.trackX;
|
||||
const int trackRight = layout.trackX + layout.trackWidth;
|
||||
|
||||
rects.reserve(static_cast<std::size_t>(tabCount));
|
||||
for (int i = 0; i < tabCount; ++i) {
|
||||
const int rawLeft = trackLeft + i * spec.tabWidth - offset;
|
||||
const int rawRight = rawLeft + spec.tabWidth;
|
||||
// Clip to the track: a partially-scrolled tab must not draw under a chevron
|
||||
// or spill past the track. A tab whose clipped extent is empty is omitted.
|
||||
int left = rawLeft < trackLeft ? trackLeft : rawLeft;
|
||||
int right = rawRight > trackRight ? trackRight : rawRight;
|
||||
if (right <= left) continue; // fully scrolled out of view either side
|
||||
TabRect r;
|
||||
r.index = i;
|
||||
r.x = left;
|
||||
r.y = strip.y;
|
||||
r.width = right - left;
|
||||
r.height = strip.height;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset) {
|
||||
TabHit miss; // {None, -1}
|
||||
if (tabCount <= 0 || strip.width <= 0 || strip.height <= 0) return miss;
|
||||
|
||||
// Reject anything outside the strip band first (half-open bounds).
|
||||
if (px < strip.x || px >= strip.x + strip.width ||
|
||||
py < strip.y || py >= strip.y + strip.height)
|
||||
return miss;
|
||||
|
||||
const TabStripLayout layout =
|
||||
computeTabStripLayout(strip, tabCount, spec, scrollOffset);
|
||||
|
||||
// Chevrons take precedence at the strip ends: a click in a reserved chevron band
|
||||
// is a scroll, never a tab (the tab track excludes those bands).
|
||||
if (layout.overflow) {
|
||||
if (px < strip.x + spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollLeft, -1};
|
||||
if (px >= strip.x + strip.width - spec.chevronWidth)
|
||||
return TabHit{TabHitKind::ScrollRight, -1};
|
||||
}
|
||||
|
||||
// Inside the track: find the visible tab whose clipped rect contains px. Reuse
|
||||
// computeTabRects so the hit matches exactly what was drawn (clipping included).
|
||||
const std::vector<TabRect> rects =
|
||||
computeTabRects(strip, tabCount, spec, scrollOffset);
|
||||
for (const TabRect& r : rects) {
|
||||
if (px >= r.x && px < r.x + r.width) return TabHit{TabHitKind::Tab, r.index};
|
||||
}
|
||||
return miss; // track dead space (no tab under the point)
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
// tab_strip — the REAPER-free layout + hit-test math behind the bank_panel's
|
||||
// named-banks tab strip (Phase B, Wave 4 — B4). The named-banks region of the
|
||||
// vertical-split bank window is a LICE-drawn tab strip (one tab per named bank,
|
||||
// NOT a SWELL-native tab control), and — from the start — it must scroll when the
|
||||
// tabs overflow the strip width (a naive fixed-width strip breaks down at ~8–12
|
||||
// tabs). What is NOT DAW-bound — how N fixed-width tabs tile a strip of a given
|
||||
// pixel width, where the overflow chevrons sit, which tab/chevron a click lands in,
|
||||
// and how far the strip may scroll — lives here so it is unit-tested outside the
|
||||
// DAW (CLAUDE.md §load-bearing split). The panel shell (bank_panel.cpp) owns the
|
||||
// SWELL window, LICE drawing, and the live BankBook read; it calls into this seam
|
||||
// for every rect and every hit. Mirror of mode_switch / bank_grid.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. Builds and unit-tests without REAPER.
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The strip the tabs are drawn into, top-left origin (SWELL/LICE convention).
|
||||
// (x, y) is the top-left corner; width/height are the strip extents. The panel
|
||||
// reserves this as a fixed-height band at the top of the named-banks region.
|
||||
struct TabStripRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const TabStripRect& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// Fixed inputs that shape the strip. tabWidth is the pixel width of each tab (fixed
|
||||
// so the strip reads as a uniform segmented control and overflow math stays simple —
|
||||
// labels ellipsize within the tab, they do not resize it). chevronWidth is the width
|
||||
// reserved at each end for the scroll affordance WHEN the tabs overflow; when they
|
||||
// fit, no chevron is reserved and the tabs use the full strip width.
|
||||
struct TabStripSpec {
|
||||
int tabWidth = 96;
|
||||
int chevronWidth = 20;
|
||||
};
|
||||
|
||||
// One tab's pixel rectangle within the strip, top-left origin, ALREADY translated
|
||||
// by the current scroll offset and clipped to the visible track. `index` is the
|
||||
// tab's index in the caller's list (ordinal order) so the shell can label/light it
|
||||
// without re-deriving. A tab scrolled fully out of view is omitted from the result
|
||||
// (the shell only draws what computeTabRects returns), so every returned rect is at
|
||||
// least partially visible.
|
||||
struct TabRect {
|
||||
int index = 0;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const TabRect& o) const {
|
||||
return index == o.index && x == o.x && y == o.y &&
|
||||
width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// The scrollable track's geometry: where the tabs may be drawn (between the
|
||||
// chevrons when overflowing, or the whole strip when they fit) and whether each
|
||||
// chevron is present. Derived once and shared by layout + hit-testing so both agree.
|
||||
struct TabStripLayout {
|
||||
bool overflow = false; // true iff N tabs at tabWidth exceed the track width
|
||||
int trackX = 0; // left edge of the tab track (past the left chevron)
|
||||
int trackWidth = 0; // width available to tabs (strip minus both chevrons)
|
||||
int maxScroll = 0; // largest valid scroll offset (0 when no overflow)
|
||||
bool leftChevron = false; // a left-scroll affordance is reserved this frame
|
||||
bool rightChevron = false;// a right-scroll affordance is reserved this frame
|
||||
};
|
||||
|
||||
// Computes the strip layout for `tabCount` tabs of `spec.tabWidth` in `strip`,
|
||||
// given the current `scrollOffset`. Pure geometry:
|
||||
// * No overflow (all tabs fit the strip width): overflow=false, no chevrons, the
|
||||
// track IS the strip, maxScroll=0.
|
||||
// * Overflow: both chevrons are reserved (chevronWidth each), the track is the
|
||||
// strip minus both chevrons, and maxScroll is the pixels by which the tab run
|
||||
// exceeds the track (so the last tab's right edge can reach the track's right
|
||||
// edge but not scroll past it). Chevrons are always both present under overflow
|
||||
// (a fixed affordance is simpler and unambiguous than hiding one at an end;
|
||||
// clicking a chevron at a scroll limit is a harmless no-op the shell clamps).
|
||||
// tabCount <= 0 or a non-positive strip width returns a zeroed layout (no overflow,
|
||||
// track == strip, maxScroll 0).
|
||||
TabStripLayout computeTabStripLayout(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// Clamps a desired scroll offset into [0, maxScroll] for the given layout. The shell
|
||||
// calls this after a chevron click / wheel so the strip never scrolls past either
|
||||
// end. maxScroll is 0 when the tabs fit, so a fitting strip always clamps to 0.
|
||||
int clampTabScroll(int desiredOffset, const TabStripLayout& layout);
|
||||
|
||||
// Tiles `tabCount` fixed-width tabs left-to-right into the layout's track, shifted
|
||||
// left by `scrollOffset`, and returns the rects that are at least partially visible
|
||||
// (in tab-index order). Each tab i sits at trackX + i*tabWidth - scrollOffset; a tab
|
||||
// whose visible extent is empty (fully left of or right of the track) is omitted.
|
||||
// Returned rects are CLIPPED to the track horizontally so a partially-scrolled tab
|
||||
// does not draw under a chevron. The caller passes the SAME scrollOffset it passed
|
||||
// to computeTabStripLayout (the shell clamps once, then uses the clamped value for
|
||||
// both). tabCount <= 0 -> empty.
|
||||
std::vector<TabRect> computeTabRects(const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
// What a point in the strip resolves to.
|
||||
enum class TabHitKind {
|
||||
None, // outside the strip, or in dead space between visible tabs
|
||||
Tab, // a tab — `index` is the tab's index in the caller's list
|
||||
ScrollLeft, // the left overflow chevron
|
||||
ScrollRight, // the right overflow chevron
|
||||
};
|
||||
|
||||
// The outcome of hit-testing a point against the strip. For Tab, `index` is the tab
|
||||
// index; for the chevrons and None it is -1.
|
||||
struct TabHit {
|
||||
TabHitKind kind = TabHitKind::None;
|
||||
int index = -1;
|
||||
|
||||
bool operator==(const TabHit& o) const {
|
||||
return kind == o.kind && index == o.index;
|
||||
}
|
||||
};
|
||||
|
||||
// Hit-tests a point (SWELL/LICE top-left client coords) against the strip laid out
|
||||
// for `tabCount` tabs at `scrollOffset`. Chevrons take precedence over tabs at the
|
||||
// strip ends (a click in the reserved chevron band is a scroll, never a tab), and a
|
||||
// point outside the strip band, or in the track but not on any visible tab, is None.
|
||||
// Half-open bounds match computeTabRects / the chevron bands so no pixel is claimed
|
||||
// twice. The shell passes the SAME clamped scrollOffset it drew with.
|
||||
TabHit hitTestTabStrip(int px, int py, const TabStripRect& strip, int tabCount,
|
||||
const TabStripSpec& spec, int scrollOffset);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,673 @@
|
||||
// Standalone tests for reasampler::bank_book — no REAPER, no test framework.
|
||||
// The heart of the multi-bank phase (Phase B1); the third instance of the pure
|
||||
// "registry + JSON round-trip, unit-tested outside the DAW" pattern.
|
||||
//
|
||||
// Covers (PLAN.md B1 test cases): pool privileges (delete/rename/evacuate rejected,
|
||||
// never zero banks); create / rename / reorder named banks; move source-loses /
|
||||
// dest-gains; copy source-retained / dest-gains; evacuate empties source into pool
|
||||
// with dest collapse; cross-bank same-hash coexistence; destination collapse on
|
||||
// move/copy into a bank already holding the hash; active-bank get/set (defaults to
|
||||
// pool, set named, invalid id); JSON round-trip lossless (full book); legacy
|
||||
// bank_index → pool migration.
|
||||
|
||||
#include "../src/bank_book.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A minimal, valid sample. `seed` disambiguates id + hash; `hash` overrides the
|
||||
// content hash so tests can force collapses. relativePath is always relative.
|
||||
static Sample sampleWith(const std::string& seed, const std::string& hash) {
|
||||
Sample s;
|
||||
s.id = "id-" + seed;
|
||||
s.displayName = "sample " + seed;
|
||||
s.relativePath = "bank/" + seed + ".wav";
|
||||
s.sourceMode = SourceMode::MasterMix;
|
||||
s.channelCount = 2;
|
||||
s.sampleRate = 48000;
|
||||
s.tier = Tier::Scratch;
|
||||
s.contentHash = hash;
|
||||
s.createdTimestamp = 1753080000LL;
|
||||
return s;
|
||||
}
|
||||
static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "hash-" + seed); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void testPoolSeededAndDefaults() {
|
||||
BankBook book;
|
||||
// Pool present as bank-zero with fixed id + name + ordinal 0.
|
||||
CHECK(book.size() == 1);
|
||||
CHECK(book.banks()[0].id == kPoolBankId);
|
||||
CHECK(book.banks()[0].displayName == std::string(kPoolBankName));
|
||||
CHECK(book.banks()[0].ordinal == 0);
|
||||
CHECK(book.pool().id == kPoolBankId);
|
||||
// Active bank defaults to the pool and resolves the pool's index.
|
||||
CHECK(book.activeBankId() == std::string(kPoolBankId));
|
||||
CHECK(&book.activeIndex() == &book.pool().index);
|
||||
}
|
||||
|
||||
static void testPoolPrivileges() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
|
||||
// Delete-pool rejected; rename-pool rejected; evacuate-pool rejected.
|
||||
CHECK(!book.deleteBank(kPoolBankId));
|
||||
CHECK(!book.renameBank(kPoolBankId, "NotPool"));
|
||||
CHECK(!book.evacuate(kPoolBankId));
|
||||
CHECK(book.pool().displayName == std::string(kPoolBankName)); // unchanged
|
||||
|
||||
// Reserved pool id cannot be minted as a named bank.
|
||||
CHECK(!book.createBank(kPoolBankId, "Imposter"));
|
||||
|
||||
// Deleting the only named bank still leaves the pool — never zero banks.
|
||||
CHECK(book.deleteBank("drums"));
|
||||
CHECK(book.size() == 1);
|
||||
CHECK(book.pool().id == kPoolBankId);
|
||||
}
|
||||
|
||||
static void testCreateRenameReorder() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Alpha"));
|
||||
CHECK(book.createBank("b", "Beta"));
|
||||
CHECK(book.createBank("c", "Gamma"));
|
||||
CHECK(book.size() == 4); // pool + 3
|
||||
|
||||
// Duplicate id rejected; empty id rejected.
|
||||
CHECK(!book.createBank("a", "dup"));
|
||||
CHECK(!book.createBank("", "empty"));
|
||||
|
||||
// Ordinals: pool 0, named 1..3 in creation order.
|
||||
CHECK(book.bank("a")->ordinal == 1);
|
||||
CHECK(book.bank("b")->ordinal == 2);
|
||||
CHECK(book.bank("c")->ordinal == 3);
|
||||
|
||||
// Rename a named bank; pool rename still rejected.
|
||||
CHECK(book.renameBank("b", "Beta-renamed"));
|
||||
CHECK(book.bank("b")->displayName == "Beta-renamed");
|
||||
CHECK(!book.renameBank("missing", "x"));
|
||||
// Renaming back to a non-colliding name keeps working.
|
||||
CHECK(book.renameBank("b", "Beta"));
|
||||
|
||||
// Reorder: move "c" to the front of the named region (ordinal 1).
|
||||
CHECK(book.reorderBank("c", 1));
|
||||
CHECK(book.pool().ordinal == 0);
|
||||
CHECK(book.bank("c")->ordinal == 1);
|
||||
CHECK(book.bank("a")->ordinal == 2);
|
||||
CHECK(book.bank("b")->ordinal == 3);
|
||||
// banks() is ordinal order, pool first.
|
||||
CHECK(book.banks()[0].id == kPoolBankId);
|
||||
CHECK(book.banks()[1].id == "c");
|
||||
CHECK(book.banks()[2].id == "a");
|
||||
CHECK(book.banks()[3].id == "b");
|
||||
|
||||
// Reorder past the end clamps to the last named slot.
|
||||
CHECK(book.reorderBank("c", 999));
|
||||
CHECK(book.banks()[3].id == "c");
|
||||
// Reorder the pool is rejected; unknown id rejected.
|
||||
CHECK(!book.reorderBank(kPoolBankId, 2));
|
||||
CHECK(!book.reorderBank("missing", 1));
|
||||
}
|
||||
|
||||
static void testDisplayNameUniqueness() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
|
||||
// A unique name is accepted.
|
||||
CHECK(book.createBank("b", "Bass"));
|
||||
|
||||
// Exact duplicate rejected, no mutation (size unchanged, the collided id absent).
|
||||
CHECK(!book.createBank("c", "Drums"));
|
||||
CHECK(book.bank("c") == nullptr);
|
||||
CHECK(book.size() == 3); // pool + a + b only
|
||||
|
||||
// Trimmed + case-insensitive collisions: "drums", " Drums ", "DRUMS" all collide.
|
||||
CHECK(!book.createBank("c", "drums"));
|
||||
CHECK(!book.createBank("c", " Drums "));
|
||||
CHECK(!book.createBank("c", "DRUMS"));
|
||||
CHECK(book.bank("c") == nullptr);
|
||||
|
||||
// The pool's reserved name "Pool" (and its variants) cannot be taken by a new bank.
|
||||
CHECK(!book.createBank("c", "Pool"));
|
||||
CHECK(!book.createBank("c", " pool "));
|
||||
CHECK(book.bank("c") == nullptr);
|
||||
|
||||
// -- renameBank uniqueness --------------------------------------------------
|
||||
// Rename to a name used by ANOTHER bank is rejected (no mutation).
|
||||
CHECK(!book.renameBank("b", "Drums"));
|
||||
CHECK(book.bank("b")->displayName == "Bass"); // unchanged
|
||||
CHECK(!book.renameBank("b", "drums")); // case-insensitive collision too
|
||||
CHECK(!book.renameBank("b", " Drums ")); // trimmed collision too
|
||||
|
||||
// Renaming a bank to its OWN current name is a no-op success (not a rejection).
|
||||
CHECK(book.renameBank("a", "Drums"));
|
||||
CHECK(book.bank("a")->displayName == "Drums");
|
||||
// Re-casing/-spacing its own name is likewise allowed (it collides only with self).
|
||||
CHECK(book.renameBank("a", " drums "));
|
||||
CHECK(book.bank("a")->displayName == " drums ");
|
||||
|
||||
// Renaming to the pool's reserved name is rejected (pool is the "other" bank here).
|
||||
CHECK(!book.renameBank("b", "Pool"));
|
||||
CHECK(book.bank("b")->displayName == "Bass");
|
||||
|
||||
// A genuinely fresh unique name still renames fine.
|
||||
CHECK(book.renameBank("b", "Low End"));
|
||||
CHECK(book.bank("b")->displayName == "Low End");
|
||||
|
||||
// After the rejections, the previously-freed name is now reusable by a new bank.
|
||||
CHECK(book.createBank("c", "Bass"));
|
||||
CHECK(book.bank("c")->displayName == "Bass");
|
||||
}
|
||||
|
||||
static void testMoveSourceLosesDestGains() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added);
|
||||
|
||||
// Move kick pool -> drums: source loses it, destination gains it.
|
||||
CHECK(book.moveSample("id-kick", kPoolBankId, "drums") == TransferResult::Moved);
|
||||
CHECK(book.pool().index.query("id-kick") == nullptr); // source lost it
|
||||
CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // dest gained it
|
||||
CHECK(book.pool().index.empty());
|
||||
CHECK(book.bank("drums")->index.size() == 1);
|
||||
|
||||
// Rejections: unknown bank, absent sample, same bank.
|
||||
CHECK(book.moveSample("id-kick", "drums", "nope") == TransferResult::RejectedUnknownBank);
|
||||
CHECK(book.moveSample("missing", "drums", kPoolBankId) == TransferResult::RejectedSampleAbsent);
|
||||
CHECK(book.moveSample("id-kick", "drums", "drums") == TransferResult::RejectedSameBank);
|
||||
// After the rejected ops the sample is still only in drums (state uncorrupted).
|
||||
CHECK(book.bank("drums")->index.query("id-kick") != nullptr);
|
||||
}
|
||||
|
||||
static void testCopySourceRetainedDestGains() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
CHECK(book.pool().index.add(sampleWith("snare")) == AddResult::Added);
|
||||
|
||||
// Copy: source retained, destination gains it — same hash in both banks (no
|
||||
// cross-bank dedup: that is the point of copy).
|
||||
CHECK(book.copySample("id-snare", kPoolBankId, "drums") == TransferResult::Copied);
|
||||
CHECK(book.pool().index.query("id-snare") != nullptr); // source retained
|
||||
CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // dest gained it
|
||||
CHECK(book.pool().index.findByHash("hash-snare") != nullptr);
|
||||
CHECK(book.bank("drums")->index.findByHash("hash-snare") != nullptr);
|
||||
}
|
||||
|
||||
static void testMoveDestCollapse() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
// Same hash already in the destination under a DIFFERENT id.
|
||||
Sample inPool = sampleWith("kick-a", "shared-hash");
|
||||
Sample inDrums = sampleWith("kick-b", "shared-hash");
|
||||
CHECK(book.pool().index.add(inPool) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added);
|
||||
|
||||
// Move the pool entry into drums: destination collapses onto its existing entry,
|
||||
// but the source STILL loses the entry (move semantics).
|
||||
CHECK(book.moveSample("id-kick-a", kPoolBankId, "drums") == TransferResult::Collapsed);
|
||||
CHECK(book.pool().index.query("id-kick-a") == nullptr); // source lost it
|
||||
CHECK(book.bank("drums")->index.size() == 1); // no duplicate
|
||||
CHECK(book.bank("drums")->index.query("id-kick-b") != nullptr);// original kept
|
||||
CHECK(book.bank("drums")->index.query("id-kick-a") == nullptr);// collapsed away
|
||||
}
|
||||
|
||||
static void testCopyDestCollapse() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
Sample inPool = sampleWith("hat-a", "hat-hash");
|
||||
Sample inDrums = sampleWith("hat-b", "hat-hash");
|
||||
CHECK(book.pool().index.add(inPool) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added);
|
||||
|
||||
// Copy into a bank already holding the hash: collapse; source retained.
|
||||
CHECK(book.copySample("id-hat-a", kPoolBankId, "drums") == TransferResult::Collapsed);
|
||||
CHECK(book.pool().index.query("id-hat-a") != nullptr); // source retained
|
||||
CHECK(book.bank("drums")->index.size() == 1); // collapsed, no dup
|
||||
CHECK(book.bank("drums")->index.query("id-hat-b") != nullptr);
|
||||
}
|
||||
|
||||
static void testCrossBankSameHashCoexistence() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
CHECK(book.createBank("hits", "Hits"));
|
||||
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
|
||||
|
||||
// Copy the same sample into two named banks — all three banks hold the hash.
|
||||
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
|
||||
CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
|
||||
CHECK(book.pool().index.findByHash("clap-hash") != nullptr);
|
||||
CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
|
||||
CHECK(book.bank("hits")->index.findByHash("clap-hash") != nullptr);
|
||||
}
|
||||
|
||||
static void testEvacuate() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("k1")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("k2")) == AddResult::Added);
|
||||
// A hash that ALSO lives in the pool already, to exercise destination collapse
|
||||
// during evacuate.
|
||||
CHECK(book.pool().index.add(sampleWith("dup-pool", "dup-hash")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("dup-drums", "dup-hash")) == AddResult::Added);
|
||||
|
||||
CHECK(book.evacuate("drums"));
|
||||
// Source emptied.
|
||||
CHECK(book.bank("drums")->index.empty());
|
||||
// Pool gained the two unique members; the dup collapsed onto the pool's existing.
|
||||
CHECK(book.pool().index.query("id-k1") != nullptr);
|
||||
CHECK(book.pool().index.query("id-k2") != nullptr);
|
||||
CHECK(book.pool().index.query("id-dup-pool") != nullptr); // original kept
|
||||
CHECK(book.pool().index.query("id-dup-drums") == nullptr); // collapsed away
|
||||
CHECK(book.pool().index.size() == 3); // k1, k2, dup-pool
|
||||
|
||||
// Evacuate an empty bank is a valid no-op success; unknown id rejected.
|
||||
CHECK(book.evacuate("drums"));
|
||||
CHECK(!book.evacuate("missing"));
|
||||
}
|
||||
|
||||
static void testActiveBank() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums"));
|
||||
// Defaults to pool.
|
||||
CHECK(book.activeBankId() == std::string(kPoolBankId));
|
||||
|
||||
// Set to a named bank; activeIndex resolves it.
|
||||
CHECK(book.setActiveBank("drums"));
|
||||
CHECK(book.activeBankId() == "drums");
|
||||
CHECK(&book.activeIndex() == &book.bank("drums")->index);
|
||||
|
||||
// Invalid id: rejected, state unchanged.
|
||||
CHECK(!book.setActiveBank("missing"));
|
||||
CHECK(book.activeBankId() == "drums");
|
||||
|
||||
// Deleting the active bank falls back to the pool.
|
||||
CHECK(book.deleteBank("drums"));
|
||||
CHECK(book.activeBankId() == std::string(kPoolBankId));
|
||||
CHECK(&book.activeIndex() == &book.pool().index);
|
||||
}
|
||||
|
||||
static void testJsonRoundTripFullBook() {
|
||||
BankBook book;
|
||||
CHECK(book.createBank("drums", "Drums \"kit\"\n")); // exercises escaping
|
||||
CHECK(book.createBank("hits", "One-Shots"));
|
||||
CHECK(book.setActiveBank("hits"));
|
||||
|
||||
// Populate per-bank indices with distinct + shared-hash samples.
|
||||
CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("d1")) == AddResult::Added);
|
||||
CHECK(book.bank("drums")->index.add(sampleWith("d2")) == AddResult::Added);
|
||||
CHECK(book.bank("hits")->index.add(sampleWith("h1")) == AddResult::Added);
|
||||
|
||||
std::string json = book.serialize();
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == book);
|
||||
// String form is idempotent too.
|
||||
if (back) CHECK(back->serialize() == json);
|
||||
|
||||
// Spot-check the reconstructed structure.
|
||||
if (back) {
|
||||
CHECK(back->activeBankId() == "hits");
|
||||
CHECK(back->size() == 3);
|
||||
CHECK(back->bank("drums") != nullptr);
|
||||
CHECK(back->bank("drums")->displayName == "Drums \"kit\"\n");
|
||||
CHECK(back->bank("drums")->index.size() == 2);
|
||||
CHECK(back->bank("hits")->index.query("id-h1") != nullptr);
|
||||
CHECK(back->pool().displayName == std::string(kPoolBankName));
|
||||
// Ordinals survived: pool 0, then named contiguously.
|
||||
CHECK(back->banks()[0].ordinal == 0);
|
||||
CHECK(back->banks()[1].ordinal == 1);
|
||||
CHECK(back->banks()[2].ordinal == 2);
|
||||
}
|
||||
}
|
||||
|
||||
static void testJsonEmptyBookRoundTrip() {
|
||||
BankBook book; // pool only, empty index, active = pool
|
||||
std::string json = book.serialize();
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == book);
|
||||
CHECK(back && back->size() == 1);
|
||||
CHECK(back && back->pool().index.empty());
|
||||
}
|
||||
|
||||
static void testLegacyMigration() {
|
||||
// A bare legacy bank_index JSON (BankIndex::serialize output — has "samples", no
|
||||
// "banks") must promote into the pool: a book of { pool } with zero named banks.
|
||||
BankIndex legacy;
|
||||
CHECK(legacy.add(sampleWith("old1")) == AddResult::Added);
|
||||
CHECK(legacy.add(sampleWith("old2")) == AddResult::Added);
|
||||
std::string legacyJson = legacy.serialize();
|
||||
|
||||
auto back = BankBook::deserialize(legacyJson);
|
||||
CHECK(back.has_value());
|
||||
if (back) {
|
||||
CHECK(back->size() == 1); // pool only
|
||||
CHECK(back->pool().id == kPoolBankId);
|
||||
CHECK(back->pool().displayName == std::string(kPoolBankName));
|
||||
CHECK(back->activeBankId() == std::string(kPoolBankId));
|
||||
CHECK(back->pool().index.size() == 2); // samples migrated
|
||||
CHECK(back->pool().index.query("id-old1") != nullptr);
|
||||
CHECK(back->pool().index.query("id-old2") != nullptr);
|
||||
// The migrated index equals the legacy index (lossless).
|
||||
CHECK(back->pool().index == legacy);
|
||||
}
|
||||
|
||||
// An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates.
|
||||
BankIndex emptyLegacy;
|
||||
auto back2 = BankBook::deserialize(emptyLegacy.serialize());
|
||||
CHECK(back2.has_value());
|
||||
CHECK(back2 && back2->size() == 1 && back2->pool().index.empty());
|
||||
}
|
||||
|
||||
static void testMalformedJson() {
|
||||
const char* bad[] = {
|
||||
"",
|
||||
"{",
|
||||
"not json",
|
||||
"{}", // neither shape marker
|
||||
"{\"banks\":[", // truncated array
|
||||
"{\"banks\":[{\"id\":\"x\"}]}", // bank missing its index
|
||||
"{\"banks\":[{\"index\":{\"samples\":[]}}]}", // bank missing its id
|
||||
"{\"banks\":[{\"id\":\"drums\",\"index\":{\"samples\":[]}}]}", // no pool
|
||||
"{\"activeBank\":\"pool\"}", // no banks + no samples marker
|
||||
"{\"banks\":[]}trailing", // trailing garbage
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
auto r = BankBook::deserialize(j);
|
||||
CHECK(!r.has_value());
|
||||
}
|
||||
|
||||
// Duplicate bank ids are malformed (ids key the registry).
|
||||
const char* dup =
|
||||
"{\"activeBank\":\"pool\",\"banks\":["
|
||||
"{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}},"
|
||||
"{\"id\":\"x\",\"displayName\":\"X\",\"ordinal\":1,\"index\":{\"samples\":[]}},"
|
||||
"{\"id\":\"x\",\"displayName\":\"X2\",\"ordinal\":2,\"index\":{\"samples\":[]}}]}";
|
||||
CHECK(!BankBook::deserialize(dup).has_value());
|
||||
}
|
||||
|
||||
// --- B2: persist-load source-precedence decision (pure) --------------------
|
||||
// loadFromPersisted picks the load source the persist shell will feed it from the
|
||||
// two ext-state values a project may carry: the authoritative `banks` blob and the
|
||||
// retired legacy `bank_index` blob. Precedence: banks > legacy > empty; a malformed
|
||||
// banks blob degrades to empty WITHOUT falling back to the stale legacy key.
|
||||
|
||||
static void testLoadPrefersBanksBlob() {
|
||||
// A full book + a stale legacy index both present: `banks` wins, legacy ignored.
|
||||
BankBook src;
|
||||
CHECK(src.createBank("drums", "Drums"));
|
||||
CHECK(src.setActiveBank("drums"));
|
||||
CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added);
|
||||
const std::string banksJson = src.serialize();
|
||||
|
||||
BankIndex stale;
|
||||
CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added);
|
||||
const std::string legacyJson = stale.serialize();
|
||||
|
||||
BankBook loaded = BankBook::loadFromPersisted(banksJson, legacyJson);
|
||||
// The book equals the source book — the legacy key had NO effect.
|
||||
CHECK(loaded == src);
|
||||
CHECK(loaded.activeBankId() == "drums");
|
||||
CHECK(loaded.bank("drums") != nullptr);
|
||||
CHECK(loaded.bank("drums")->index.query("id-new1") != nullptr);
|
||||
// The stale legacy sample must NOT have leaked into the pool.
|
||||
CHECK(loaded.pool().index.query("id-stale-old") == nullptr);
|
||||
}
|
||||
|
||||
static void testLoadMigratesLegacyWhenNoBanks() {
|
||||
// No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named.
|
||||
BankIndex legacy;
|
||||
CHECK(legacy.add(sampleWith("l1")) == AddResult::Added);
|
||||
CHECK(legacy.add(sampleWith("l2")) == AddResult::Added);
|
||||
const std::string legacyJson = legacy.serialize();
|
||||
|
||||
BankBook loaded = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
CHECK(loaded.size() == 1); // pool only
|
||||
CHECK(loaded.pool().id == kPoolBankId);
|
||||
CHECK(loaded.activeBankId() == std::string(kPoolBankId));
|
||||
CHECK(loaded.pool().index.size() == 2);
|
||||
CHECK(loaded.pool().index == legacy); // lossless
|
||||
}
|
||||
|
||||
static void testLoadEmptyWhenNeither() {
|
||||
// Both absent: a fresh empty book (pool only, empty index, active = pool).
|
||||
BankBook loaded = BankBook::loadFromPersisted(std::string{}, std::string{});
|
||||
CHECK(loaded == BankBook{});
|
||||
CHECK(loaded.size() == 1);
|
||||
CHECK(loaded.pool().index.empty());
|
||||
CHECK(loaded.activeBankId() == std::string(kPoolBankId));
|
||||
}
|
||||
|
||||
static void testLoadMalformedBanksDegradesWithoutLegacyFallback() {
|
||||
// A present-but-malformed `banks` blob must degrade to an empty book and must NOT
|
||||
// resurrect the stale legacy key (that would revive superseded single-bank state).
|
||||
BankIndex stale;
|
||||
CHECK(stale.add(sampleWith("stale")) == AddResult::Added);
|
||||
const std::string legacyJson = stale.serialize();
|
||||
|
||||
BankBook loaded = BankBook::loadFromPersisted("{\"banks\":[", legacyJson);
|
||||
CHECK(loaded == BankBook{}); // empty, NOT the legacy
|
||||
CHECK(loaded.pool().index.query("id-stale") == nullptr); // legacy did not leak
|
||||
}
|
||||
|
||||
// --- B3: active-bank cycle ordering (pure free function) -------------------
|
||||
// nextBankId(orderedIds, current) is the pure decision behind the "cycle active
|
||||
// bank" action: given the book's ordered bank ids (pool-first) + the current active
|
||||
// id, return the next id in ordinal order, wrapping pool -> named -> ... -> pool.
|
||||
|
||||
static void testCycleOrderingWrapAround() {
|
||||
// pool -> drums -> hits -> (wrap) pool. Exercises every step + the wrap.
|
||||
const std::vector<std::string> ids = {kPoolBankId, "drums", "hits"};
|
||||
CHECK(nextBankId(ids, kPoolBankId) == "drums");
|
||||
CHECK(nextBankId(ids, "drums") == "hits");
|
||||
CHECK(nextBankId(ids, "hits") == std::string(kPoolBankId)); // wrap past the last
|
||||
}
|
||||
|
||||
static void testCyclePoolOnlyStaysPool() {
|
||||
// A pool-only book (no named banks) cycles to itself — the single id wraps to
|
||||
// itself. The action becomes a no-op activation, which is correct.
|
||||
const std::vector<std::string> ids = {kPoolBankId};
|
||||
CHECK(nextBankId(ids, kPoolBankId) == std::string(kPoolBankId));
|
||||
}
|
||||
|
||||
static void testCycleUnknownActiveResolvesToFirst() {
|
||||
// A stale/unknown active id (e.g. the active bank was just deleted and the
|
||||
// ordered list already dropped it) resolves to the first id — a sane home to jump
|
||||
// to rather than "" — matching nextModeId's fallback.
|
||||
const std::vector<std::string> ids = {kPoolBankId, "drums"};
|
||||
CHECK(nextBankId(ids, "ghost") == std::string(kPoolBankId));
|
||||
}
|
||||
|
||||
static void testCycleEmptyListYieldsEmpty() {
|
||||
// Degenerate guard: an empty list has nothing to cycle to. (A real BankBook always
|
||||
// seeds the pool, so this cannot arise from the book — but the pure helper must not
|
||||
// index into an empty vector.)
|
||||
const std::vector<std::string> ids;
|
||||
CHECK(nextBankId(ids, kPoolBankId).empty());
|
||||
}
|
||||
|
||||
static void testCycleMatchesBookOrdinalOrder() {
|
||||
// Integration-flavoured but still pure: drive the cycle off a real book's banks()
|
||||
// order and confirm one full loop lands back on the pool, activating each bank in
|
||||
// ordinal order. This is exactly what the action does (build ids from banks(),
|
||||
// call nextBankId, setActiveBank).
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "A"));
|
||||
CHECK(book.createBank("b", "B")); // ordinals: pool 0, a 1, b 2
|
||||
|
||||
std::vector<std::string> ids;
|
||||
for (const Bank& bk : book.banks()) ids.push_back(bk.id);
|
||||
|
||||
std::string cur = book.activeBankId(); // pool
|
||||
cur = nextBankId(ids, cur); CHECK(cur == "a");
|
||||
cur = nextBankId(ids, cur); CHECK(cur == "b");
|
||||
cur = nextBankId(ids, cur); CHECK(cur == std::string(kPoolBankId)); // full loop
|
||||
}
|
||||
|
||||
static void testActiveBankResolveAfterCorruptPersistedId() {
|
||||
// A book blob whose activeBank names no bank resolves to the pool (defensive).
|
||||
const char* json =
|
||||
"{\"activeBank\":\"ghost\",\"banks\":["
|
||||
"{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}]}";
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && back->activeBankId() == std::string(kPoolBankId));
|
||||
}
|
||||
|
||||
// --- B4 fold-in: deserialize coalesces duplicate folded display names --------
|
||||
//
|
||||
// The in-model create/rename path enforces unique display names under the trimmed +
|
||||
// case-insensitive fold, but a hand-edited .rpp blob can carry two banks whose names
|
||||
// fold to the same key. deserialize must NOT reject the whole book (that would drop
|
||||
// the user's entire library over one collision) — it AUTO-DISAMBIGUATES the later
|
||||
// duplicate deterministically so the book loads intact with unique names, all banks
|
||||
// and samples preserved, and ids untouched.
|
||||
|
||||
// Rewrites the first occurrence of `from` in `s` to `to` (test helper: injects a
|
||||
// colliding display name into a serialized blob to simulate a hand-edit).
|
||||
static std::string replaceFirst(std::string s, const std::string& from,
|
||||
const std::string& to) {
|
||||
const auto pos = s.find(from);
|
||||
if (pos != std::string::npos) s.replace(pos, from.size(), to);
|
||||
return s;
|
||||
}
|
||||
|
||||
static void testDeserializeCoalescesDuplicateFoldedNames() {
|
||||
// Build a real book with two distinctly-named banks each holding a sample, then
|
||||
// corrupt the second bank's display name so it folds to the first's key
|
||||
// (" drums " folds to "drums", same as "Drums"). This is exactly what a
|
||||
// hand-edited blob would look like.
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
CHECK(book.createBank("b", "Bass"));
|
||||
CHECK(book.bank("a")->index.add(sampleWith("a1")) == AddResult::Added);
|
||||
CHECK(book.bank("b")->index.add(sampleWith("b1")) == AddResult::Added);
|
||||
|
||||
const std::string json = book.serialize();
|
||||
// Rename bank "b" from "Bass" to " drums " (folds to "drums") — a duplicate of "a".
|
||||
const std::string corrupted =
|
||||
replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\" drums \"");
|
||||
CHECK(corrupted != json); // the substitution landed
|
||||
|
||||
auto back = BankBook::deserialize(corrupted);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
|
||||
// The book loaded intact: pool + 2 named banks, no bank lost.
|
||||
CHECK(back->size() == 3);
|
||||
// Ids are preserved (disambiguation touches names only, never ids).
|
||||
CHECK(back->bank("a") != nullptr);
|
||||
CHECK(back->bank("b") != nullptr);
|
||||
// The FIRST bank to carry the folded key keeps its name; the later one is
|
||||
// suffixed to a unique name.
|
||||
CHECK(back->bank("a")->displayName == "Drums");
|
||||
CHECK(back->bank("b")->displayName != back->bank("a")->displayName);
|
||||
|
||||
// The disambiguated names are genuinely unique under the model's own fold — the
|
||||
// book can now round-trip through the in-model uniqueness invariant. Prove it by
|
||||
// re-serializing and re-parsing: idempotent, no further renames.
|
||||
const std::string json2 = back->serialize();
|
||||
auto back2 = BankBook::deserialize(json2);
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == json2);
|
||||
|
||||
// No sample was lost across the coalesce.
|
||||
CHECK(back->bank("a")->index.size() == 1);
|
||||
CHECK(back->bank("b")->index.size() == 1);
|
||||
CHECK(back->bank("a")->index.query("id-a1") != nullptr);
|
||||
CHECK(back->bank("b")->index.query("id-b1") != nullptr);
|
||||
}
|
||||
|
||||
static void testDeserializeCoalescesMultipleCollisions() {
|
||||
// Three banks all folding to the same key: the first keeps its name, the next two
|
||||
// get distinct suffixes so all three end unique (no two disambiguate to the same).
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
CHECK(book.createBank("b", "Bass"));
|
||||
CHECK(book.createBank("c", "Keys"));
|
||||
|
||||
std::string json = book.serialize();
|
||||
json = replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\"drums\"");
|
||||
json = replaceFirst(json, "\"displayName\":\"Keys\"", "\"displayName\":\"DRUMS\"");
|
||||
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
CHECK(back->size() == 4); // pool + 3, none lost
|
||||
|
||||
// All three named banks carry distinct folded keys after coalesce.
|
||||
const std::string na = back->bank("a")->displayName;
|
||||
const std::string nb = back->bank("b")->displayName;
|
||||
const std::string nc = back->bank("c")->displayName;
|
||||
CHECK(na != nb);
|
||||
CHECK(na != nc);
|
||||
CHECK(nb != nc);
|
||||
|
||||
// Re-parse proves the result satisfies the round-trip (unique keys throughout).
|
||||
auto back2 = BankBook::deserialize(back->serialize());
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == back->serialize());
|
||||
}
|
||||
|
||||
static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
|
||||
// A named bank whose name folds to the pool's reserved "Pool" key is renamed away
|
||||
// from the pool (never the reverse — the pool's name is fixed and reserved).
|
||||
BankBook book;
|
||||
CHECK(book.createBank("a", "Drums"));
|
||||
std::string json = book.serialize();
|
||||
json = replaceFirst(json, "\"displayName\":\"Drums\"", "\"displayName\":\"pool\"");
|
||||
|
||||
auto back = BankBook::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
if (!back) return;
|
||||
CHECK(back->size() == 2);
|
||||
// The pool keeps its authoritative name; the named bank is disambiguated off it.
|
||||
CHECK(back->pool().displayName == std::string(kPoolBankName));
|
||||
CHECK(back->bank("a") != nullptr);
|
||||
CHECK(back->bank("a")->displayName != std::string(kPoolBankName));
|
||||
// And it is not any case/space variant that would re-collide with "Pool".
|
||||
auto back2 = BankBook::deserialize(back->serialize());
|
||||
CHECK(back2.has_value());
|
||||
if (back2) CHECK(back2->serialize() == back->serialize());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPoolSeededAndDefaults();
|
||||
testPoolPrivileges();
|
||||
testCreateRenameReorder();
|
||||
testDisplayNameUniqueness();
|
||||
testMoveSourceLosesDestGains();
|
||||
testCopySourceRetainedDestGains();
|
||||
testMoveDestCollapse();
|
||||
testCopyDestCollapse();
|
||||
testCrossBankSameHashCoexistence();
|
||||
testEvacuate();
|
||||
testActiveBank();
|
||||
testJsonRoundTripFullBook();
|
||||
testJsonEmptyBookRoundTrip();
|
||||
testLegacyMigration();
|
||||
testMalformedJson();
|
||||
testLoadPrefersBanksBlob();
|
||||
testLoadMigratesLegacyWhenNoBanks();
|
||||
testLoadEmptyWhenNeither();
|
||||
testLoadMalformedBanksDegradesWithoutLegacyFallback();
|
||||
testActiveBankResolveAfterCorruptPersistedId();
|
||||
testCycleOrderingWrapAround();
|
||||
testCyclePoolOnlyStaysPool();
|
||||
testCycleUnknownActiveResolvesToFirst();
|
||||
testCycleEmptyListYieldsEmpty();
|
||||
testCycleMatchesBookOrdinalOrder();
|
||||
testDeserializeCoalescesDuplicateFoldedNames();
|
||||
testDeserializeCoalescesMultipleCollisions();
|
||||
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Standalone tests for reasampler::tab_strip — no REAPER, no test framework. Same
|
||||
// fast loop as the sibling pure tests (mode_switch / bank_grid et al.): assert the
|
||||
// named-banks tab-strip layout, overflow/scroll math, and hit-testing directly.
|
||||
//
|
||||
// Covers (B4 brief §unit-test the pure seam): no-overflow tiling (tabs fit, no
|
||||
// chevrons, track == strip); overflow (chevrons reserved, track shrinks, maxScroll
|
||||
// = run - track); scroll clamping to [0, maxScroll]; clipped visible rects (a
|
||||
// partially-scrolled tab is clipped to the track, a fully-scrolled-out tab is
|
||||
// omitted); scrolling to the end surfaces the last tab; hit-testing (tab hit,
|
||||
// left/right chevron precedence at the ends, dead space between visible tabs,
|
||||
// outside the band above/below/left/right, half-open boundary pixels).
|
||||
|
||||
#include "../src/tab_strip.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- No overflow: tabs fit ---------------------------------------------------
|
||||
|
||||
// 3 tabs at 96px = 288 fit a 300-wide strip: no overflow, no chevrons, the whole
|
||||
// strip is the track, maxScroll 0.
|
||||
static void testFitsNoOverflow() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 3, spec, 0);
|
||||
CHECK(!layout.overflow);
|
||||
CHECK(!layout.leftChevron && !layout.rightChevron);
|
||||
CHECK(layout.trackX == 0);
|
||||
CHECK(layout.trackWidth == 300);
|
||||
CHECK(layout.maxScroll == 0);
|
||||
|
||||
auto rects = computeTabRects(strip, 3, spec, 0);
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK((rects[0] == TabRect{0, 0, 0, 96, 24}));
|
||||
CHECK((rects[1] == TabRect{1, 96, 0, 96, 24}));
|
||||
CHECK((rects[2] == TabRect{2, 192, 0, 96, 24}));
|
||||
}
|
||||
|
||||
// A fitting strip ignores a stray non-zero scroll offset (maxScroll 0 clamps it).
|
||||
static void testFitStripIgnoresScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
auto rects = computeTabRects(strip, 3, spec, /*scrollOffset=*/500);
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK(rects[0].x == 0); // offset was clamped to 0
|
||||
}
|
||||
|
||||
// --- Overflow: chevrons reserved, track shrinks ------------------------------
|
||||
|
||||
// 10 tabs at 96 = 960 overflow a 300-wide strip. Chevrons (20 each) are reserved,
|
||||
// so the track is [20, 280) = 260 wide; maxScroll = 960 - 260 = 700.
|
||||
static void testOverflowReservesChevrons() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
CHECK(layout.overflow);
|
||||
CHECK(layout.leftChevron && layout.rightChevron);
|
||||
CHECK(layout.trackX == 20);
|
||||
CHECK(layout.trackWidth == 260);
|
||||
CHECK(layout.maxScroll == 700);
|
||||
}
|
||||
|
||||
// At scroll 0 the first tabs are visible from the track's left edge; a tab that
|
||||
// straddles the right chevron is clipped to the track's right edge, and tabs fully
|
||||
// past it are omitted.
|
||||
static void testOverflowScrollZeroClipsRight() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
auto rects = computeTabRects(strip, 10, spec, 0);
|
||||
// Track is [20, 280). Tab 0 at [20,116), tab 1 [116,212), tab 2 [212,308) clipped
|
||||
// to [212,280). Tabs 3.. start past 280 -> omitted.
|
||||
CHECK(rects.size() == 3);
|
||||
CHECK((rects[0] == TabRect{0, 20, 0, 96, 24}));
|
||||
CHECK((rects[1] == TabRect{1, 116, 0, 96, 24}));
|
||||
CHECK((rects[2] == TabRect{2, 212, 0, 68, 24})); // clipped at the track's right
|
||||
}
|
||||
|
||||
// Scrolling to maxScroll surfaces the LAST tab flush against the track's right edge
|
||||
// and drops the earliest tabs off the left. This is the property that makes overflow
|
||||
// usable: every tab is reachable by scrolling.
|
||||
static void testScrollToEndSurfacesLastTab() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
auto rects = computeTabRects(strip, 10, spec, layout.maxScroll);
|
||||
CHECK(!rects.empty());
|
||||
const TabRect& last = rects.back();
|
||||
CHECK(last.index == 9); // the last tab is visible
|
||||
CHECK(last.x + last.width == layout.trackX + layout.trackWidth); // flush right (280)
|
||||
}
|
||||
|
||||
// --- Scroll clamping ---------------------------------------------------------
|
||||
|
||||
static void testClampScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
TabStripLayout layout = computeTabStripLayout(strip, 10, spec, 0);
|
||||
CHECK(clampTabScroll(-50, layout) == 0);
|
||||
CHECK(clampTabScroll(0, layout) == 0);
|
||||
CHECK(clampTabScroll(300, layout) == 300);
|
||||
CHECK(clampTabScroll(layout.maxScroll, layout) == layout.maxScroll);
|
||||
CHECK(clampTabScroll(layout.maxScroll + 999, layout) == layout.maxScroll);
|
||||
|
||||
TabStripLayout fits = computeTabStripLayout(strip, 2, spec, 0);
|
||||
CHECK(clampTabScroll(123, fits) == 0); // no overflow -> everything clamps to 0
|
||||
}
|
||||
|
||||
// --- Hit-testing -------------------------------------------------------------
|
||||
|
||||
// No overflow: a point in a tab returns that tab; the gap-free tiling means every
|
||||
// x in the strip band lands on some tab.
|
||||
static void testHitFitStrip() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
CHECK((hitTestTabStrip(10, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 0}));
|
||||
CHECK((hitTestTabStrip(100, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 1}));
|
||||
CHECK((hitTestTabStrip(250, 12, strip, 3, spec, 0) == TabHit{TabHitKind::Tab, 2}));
|
||||
// Outside the band: above, below, left, right all miss.
|
||||
CHECK((hitTestTabStrip(10, -1, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(10, 24, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(-1, 12, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
CHECK((hitTestTabStrip(300, 12, strip, 3, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
}
|
||||
|
||||
// Overflow: the reserved chevron bands hit-test to the scroll affordances and take
|
||||
// precedence over any tab that would otherwise sit there.
|
||||
static void testHitChevrons() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
// Left chevron band [0,20).
|
||||
CHECK((hitTestTabStrip(5, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollLeft, -1}));
|
||||
CHECK((hitTestTabStrip(19, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollLeft, -1}));
|
||||
// Right chevron band [280,300).
|
||||
CHECK((hitTestTabStrip(280, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollRight, -1}));
|
||||
CHECK((hitTestTabStrip(299, 12, strip, 10, spec, 0) ==
|
||||
TabHit{TabHitKind::ScrollRight, -1}));
|
||||
// Just inside the track (x=20) is the first tab, not the left chevron.
|
||||
CHECK((hitTestTabStrip(20, 12, strip, 10, spec, 0) == TabHit{TabHitKind::Tab, 0}));
|
||||
}
|
||||
|
||||
// A hit in the track matches the drawn (clipped) rects; a point in track dead space
|
||||
// (no visible tab under it) is None. With overflow at scroll 0 the visible tabs are
|
||||
// 0,1,2 (2 clipped to [212,280)); everything in [20,280) is covered here, so we test
|
||||
// dead space by scrolling so a tab boundary leaves no gap — instead assert the hit
|
||||
// agrees with computeTabRects for a mid-scroll offset.
|
||||
static void testHitMatchesRectsMidScroll() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
const int offset = 150;
|
||||
auto rects = computeTabRects(strip, 10, spec, offset);
|
||||
CHECK(!rects.empty());
|
||||
for (const TabRect& r : rects) {
|
||||
// A point at the rect's left edge and one just inside its right edge both
|
||||
// resolve to this tab (half-open bounds).
|
||||
CHECK((hitTestTabStrip(r.x, 12, strip, 10, spec, offset) ==
|
||||
TabHit{TabHitKind::Tab, r.index}));
|
||||
CHECK((hitTestTabStrip(r.x + r.width - 1, 12, strip, 10, spec, offset) ==
|
||||
TabHit{TabHitKind::Tab, r.index}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Degenerate inputs -------------------------------------------------------
|
||||
|
||||
static void testDegenerate() {
|
||||
TabStripRect strip{0, 0, 300, 24};
|
||||
TabStripSpec spec{96, 20};
|
||||
CHECK(computeTabRects(strip, 0, spec, 0).empty());
|
||||
CHECK((hitTestTabStrip(10, 12, strip, 0, spec, 0) == TabHit{TabHitKind::None, -1}));
|
||||
|
||||
TabStripRect empty{0, 0, 0, 24};
|
||||
CHECK(computeTabRects(empty, 3, spec, 0).empty());
|
||||
TabStripLayout layout = computeTabStripLayout(empty, 3, spec, 0);
|
||||
CHECK(!layout.overflow);
|
||||
CHECK(layout.maxScroll == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFitsNoOverflow();
|
||||
testFitStripIgnoresScroll();
|
||||
testOverflowReservesChevrons();
|
||||
testOverflowScrollZeroClipsRight();
|
||||
testScrollToEndSurfacesLastTab();
|
||||
testClampScroll();
|
||||
testHitFitStrip();
|
||||
testHitChevrons();
|
||||
testHitMatchesRectsMidScroll();
|
||||
testDegenerate();
|
||||
|
||||
if (g_fail == 0) std::printf("tab_strip: all tests passed\n");
|
||||
else std::printf("tab_strip: %d FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user