Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
#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,
|
||||
// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are
|
||||
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
|
||||
// index-only (remove from source's BankModel, 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 <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/model/slot_map.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the
|
||||
// clean model types it wraps live in reasampler::model.
|
||||
using namespace model;
|
||||
|
||||
// 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";
|
||||
|
||||
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h.
|
||||
// Included above because Bank carries one per bank.
|
||||
|
||||
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
|
||||
// own BankModel. 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
|
||||
BankModel index; // this bank's samples
|
||||
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
|
||||
|
||||
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 && slots == o.slots;
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
|
||||
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
|
||||
// live and tested at the model level, promotable later behind this parameter without
|
||||
// a rewrite, but never wired to an affordance in B5.
|
||||
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
|
||||
// in another bank survives (no cross-bank cascade — dedup is per-bank).
|
||||
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
|
||||
// ("purge from the library"). Latent; unsurfaced.
|
||||
enum class RemoveScope {
|
||||
ThisBank,
|
||||
AllBanks,
|
||||
};
|
||||
|
||||
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
|
||||
// what happened rather than silently mutating on a bad request.
|
||||
// - Removed: at least one index entry was dropped.
|
||||
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
||||
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
||||
enum class RemoveResult {
|
||||
Removed,
|
||||
RejectedUnknownBank,
|
||||
RejectedSampleAbsent,
|
||||
};
|
||||
|
||||
// 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 BankModel — the index the capture layer adds to. Always
|
||||
// valid (the active id always names a live bank; it falls back to the pool).
|
||||
BankModel& activeIndex();
|
||||
const BankModel& 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);
|
||||
|
||||
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
||||
|
||||
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
|
||||
// Index-only and non-destructive to the file: a last-reference remove leaves the
|
||||
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
|
||||
//
|
||||
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
|
||||
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
|
||||
// that holds it. See RemoveResult for the outcome set.
|
||||
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
|
||||
// if that bank does not hold the id; Removed on a drop.
|
||||
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
|
||||
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
|
||||
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
|
||||
RemoveResult removeSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
RemoveScope scope = RemoveScope::ThisBank);
|
||||
|
||||
// -- Sample display order (L7; index membership untouched) ---------------
|
||||
|
||||
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
|
||||
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
|
||||
// membership first (drops stale markers, appends unmapped samples densely), so a
|
||||
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
|
||||
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
|
||||
// it is a non-const member.
|
||||
std::vector<std::string> orderedSampleIds(const std::string& bankId);
|
||||
|
||||
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
|
||||
// map that has NO overlap with its index from insertion order (the pre-L7 migration
|
||||
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
|
||||
// append unmapped). Idempotent. Called after deserialize and after any capture/
|
||||
// transfer that added samples out-of-band of the L7 reorder path.
|
||||
void reconcileSlots();
|
||||
|
||||
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
|
||||
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
|
||||
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
|
||||
// first so the target space is complete. Returns false (no mutation) on an unknown
|
||||
// bank or an id the bank does not hold.
|
||||
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
|
||||
|
||||
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
|
||||
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
|
||||
// index (index-only, same semantics as removeSample ThisBank — the file stays on
|
||||
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
|
||||
// last-reference case). Position of the slot is preserved; only its occupant changes.
|
||||
//
|
||||
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
|
||||
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
|
||||
// For the pool this is permitted whenever the occupant exists (per-sample removal
|
||||
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
|
||||
// never per-sample remove). If the removal would be rejected (occupant absent), the
|
||||
// whole replace is rejected: false, NO mutation (neither the index nor the slots
|
||||
// change), so the shell can fall back to the default insert-shift or a no-op.
|
||||
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
|
||||
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
|
||||
bool replaceSample(const std::string& newId, const std::string& oldId,
|
||||
const std::string& bankId);
|
||||
|
||||
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
|
||||
// finds the bank holding `sampleId` and replaces its entry with `updated`
|
||||
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in
|
||||
// ordinal order and updates the FIRST holder (a sample id is unique within a
|
||||
// bank; the same id living in two banks via copy would update the earliest, which
|
||||
// is acceptable — re-capture operates on the panel's focused single selection).
|
||||
// Returns false (no mutation) if no bank holds the id or the replacement's path
|
||||
// is absolute. Index-only and non-destructive to the timeline.
|
||||
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
||||
|
||||
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
||||
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
||||
//
|
||||
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
|
||||
// the whole model already reasons in (findByHash / collapse-by-hash), and two
|
||||
// entries that share content share one file — so "some other bank still references
|
||||
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
|
||||
// never matched (it does not participate in dedup, mirroring findByHash), so an
|
||||
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
|
||||
// direction (we cannot prove another bank shares an unhashed file).
|
||||
bool hashReferencedElsewhere(const std::string& hash,
|
||||
const std::string& exceptBankId) const;
|
||||
|
||||
// Every project-relative file path referenced by ANY bank in the book, pool
|
||||
// included — the union across the whole book (Phase R, prune). This is the
|
||||
// safety-critical referenced-set the prune core subtracts: a file referenced by
|
||||
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
|
||||
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
|
||||
// no normalization), first-seen order across banks in ordinal order then sample
|
||||
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
|
||||
// once). An empty relativePath is skipped (it references no file). Additive
|
||||
// read-only query; adds no mutation and no coupling to Phase R.
|
||||
std::vector<std::string> referencedPaths() const;
|
||||
|
||||
// -- 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 BankModel by id, or nullptr. Convenience over bank()->index.
|
||||
BankModel* index(const std::string& id);
|
||||
const BankModel* 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
|
||||
Reference in New Issue
Block a user