L7: capture-order slot model, card metadata overlay, tertiary-border selection
Add gap-preserving per-bank SlotMap (reorder + Alt-replace mutators, JSON round-trip, insertion-order migration) to bank_book; stamp captureTimeSig on Sample; pure card_meta formatters + card_drag gesture/slot module; card metadata overlay + purple selection border in the panel. Shell drop/cursor wiring deferred. Fixes: removeSample syncs SlotMap; card_drag gap-probe coordinate.
This commit is contained in:
+260
-3
@@ -18,6 +18,127 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ===========================================================================
|
||||
// SlotMap — the L7 gap-preserving display-position carrier (pure). See bank_book.h.
|
||||
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
|
||||
// slot per id. Every mutator restores it; queries assume it.
|
||||
// ===========================================================================
|
||||
|
||||
void SlotMap::sortBySlot() {
|
||||
std::stable_sort(entries_.begin(), entries_.end(),
|
||||
[](const Entry& a, const Entry& b) { return a.slot < b.slot; });
|
||||
}
|
||||
|
||||
int SlotMap::slotOf(const std::string& id) const {
|
||||
for (const auto& e : entries_)
|
||||
if (e.id == id) return e.slot;
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::string SlotMap::idAt(int slot) const {
|
||||
for (const auto& e : entries_)
|
||||
if (e.slot == slot) return e.id;
|
||||
return {};
|
||||
}
|
||||
|
||||
int SlotMap::maxSlot() const {
|
||||
int m = -1;
|
||||
for (const auto& e : entries_)
|
||||
if (e.slot > m) m = e.slot;
|
||||
return m;
|
||||
}
|
||||
|
||||
std::vector<std::string> SlotMap::orderedIds() const {
|
||||
// entries_ is sorted ascending by slot, so a straight walk is display order.
|
||||
std::vector<std::string> out;
|
||||
out.reserve(entries_.size());
|
||||
for (const auto& e : entries_) out.push_back(e.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
void SlotMap::append(const std::string& id) {
|
||||
if (id.empty()) return;
|
||||
remove(id); // an existing id is re-appended, not left in place
|
||||
entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied
|
||||
sortBySlot();
|
||||
}
|
||||
|
||||
bool SlotMap::remove(const std::string& id) {
|
||||
for (auto it = entries_.begin(); it != entries_.end(); ++it) {
|
||||
if (it->id == id) {
|
||||
entries_.erase(it); // leaves the slot empty — no re-pack
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SlotMap::reorder(const std::string& id, int targetSlot) {
|
||||
if (slotOf(id) < 0) return false; // not mapped -> no mutation
|
||||
if (targetSlot < 0) targetSlot = 0;
|
||||
|
||||
// Detach the moving id first so the occupancy test below sees the post-move world.
|
||||
remove(id);
|
||||
|
||||
const bool occupied = !idAt(targetSlot).empty();
|
||||
if (occupied) {
|
||||
// Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one,
|
||||
// preserving relative order and interior gaps above the target. The moving id then
|
||||
// takes targetSlot cleanly.
|
||||
for (auto& e : entries_)
|
||||
if (e.slot >= targetSlot) ++e.slot;
|
||||
}
|
||||
entries_.push_back(Entry{id, targetSlot});
|
||||
sortBySlot();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SlotMap::resetDense(const std::vector<std::string>& ids) {
|
||||
entries_.clear();
|
||||
int slot = 0;
|
||||
for (const auto& id : ids) {
|
||||
if (id.empty()) continue;
|
||||
if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id)
|
||||
entries_.push_back(Entry{id, slot++});
|
||||
}
|
||||
// Already ascending by construction; no sort needed.
|
||||
}
|
||||
|
||||
void SlotMap::reconcile(const std::vector<std::string>& liveIds) {
|
||||
// Drop markers whose sample left the index.
|
||||
entries_.erase(
|
||||
std::remove_if(entries_.begin(), entries_.end(),
|
||||
[&](const Entry& e) {
|
||||
return std::find(liveIds.begin(), liveIds.end(), e.id) ==
|
||||
liveIds.end();
|
||||
}),
|
||||
entries_.end());
|
||||
// Append live ids that have no mapping yet (out-of-band index growth), in liveIds
|
||||
// order, each to the next free slot after the current frontier.
|
||||
for (const auto& id : liveIds)
|
||||
if (slotOf(id) < 0) append(id);
|
||||
sortBySlot();
|
||||
}
|
||||
|
||||
bool SlotMap::operator==(const SlotMap& o) const {
|
||||
return entries_ == o.entries_;
|
||||
}
|
||||
|
||||
SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pairs) {
|
||||
SlotMap m;
|
||||
for (const auto& [id, slot] : pairs) {
|
||||
if (id.empty() || slot < 0) continue; // drop malformed pair
|
||||
if (m.slotOf(id) >= 0) continue; // duplicate id: first wins
|
||||
if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy
|
||||
m.entries_.push_back(Entry{id, slot});
|
||||
}
|
||||
m.sortBySlot();
|
||||
return m;
|
||||
}
|
||||
|
||||
// SlotMap::serialize is defined in the JSON writer section below (it reuses the
|
||||
// file-local ObjWriter / intToStr helpers).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankBook — construction + bank lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -294,15 +415,19 @@ RemoveResult BankBook::removeSample(const std::string& sampleId,
|
||||
// ignored (the id is dropped book-wide). Removed iff at least one drop landed.
|
||||
bool any = false;
|
||||
for (auto& b : banks_)
|
||||
if (b.index.remove(sampleId)) any = true;
|
||||
if (b.index.remove(sampleId)) {
|
||||
b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap
|
||||
any = true;
|
||||
}
|
||||
return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
|
||||
}
|
||||
|
||||
// ThisBank (default, the only surfaced verb): drop from the one named source bank.
|
||||
Bank* from = bank(fromBankId);
|
||||
if (from == nullptr) return RemoveResult::RejectedUnknownBank;
|
||||
return from->index.remove(sampleId) ? RemoveResult::Removed
|
||||
: RemoveResult::RejectedSampleAbsent;
|
||||
if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent;
|
||||
from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap
|
||||
return RemoveResult::Removed;
|
||||
}
|
||||
|
||||
bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) {
|
||||
@@ -312,6 +437,86 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up
|
||||
return false; // no bank holds the id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample display order (L7) — SlotMap driven, index membership untouched
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed.
|
||||
std::vector<std::string> indexIds(const BankIndex& idx) {
|
||||
std::vector<std::string> ids;
|
||||
for (const auto& s : idx.all()) ids.push_back(s.id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
|
||||
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
|
||||
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
|
||||
void reconcileBankSlots(Bank& b) {
|
||||
const std::vector<std::string> live = indexIds(b.index);
|
||||
if (b.slots.empty()) {
|
||||
b.slots.resetDense(live); // migration / first-population default: dense, no gaps
|
||||
return;
|
||||
}
|
||||
b.slots.reconcile(live); // partial map: keep positions, drop stale, append new
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BankBook::reconcileSlots() {
|
||||
for (auto& b : banks_) reconcileBankSlots(b);
|
||||
}
|
||||
|
||||
std::vector<std::string> BankBook::orderedSampleIds(const std::string& bankId) {
|
||||
Bank* b = bank(bankId);
|
||||
if (b == nullptr) return {};
|
||||
reconcileBankSlots(*b); // ensure the map covers all live members
|
||||
return b->slots.orderedIds();
|
||||
}
|
||||
|
||||
bool BankBook::reorderSample(const std::string& id, const std::string& bankId,
|
||||
int targetSlot) {
|
||||
Bank* b = bank(bankId);
|
||||
if (b == nullptr) return false;
|
||||
if (b->index.query(id) == nullptr) return false; // bank does not hold the sample
|
||||
reconcileBankSlots(*b); // complete the target space first
|
||||
return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched
|
||||
}
|
||||
|
||||
bool BankBook::replaceSample(const std::string& newId, const std::string& oldId,
|
||||
const std::string& bankId) {
|
||||
if (newId == oldId) return false;
|
||||
Bank* b = bank(bankId);
|
||||
if (b == nullptr) return false;
|
||||
// Both the dragged sample and the occupant must live in this bank.
|
||||
if (b->index.query(newId) == nullptr) return false;
|
||||
if (b->index.query(oldId) == nullptr) return false;
|
||||
|
||||
reconcileBankSlots(*b); // complete the map so oldId's slot is known
|
||||
|
||||
// Capture the target slot BEFORE any mutation so the position survives the removal.
|
||||
const int targetSlot = b->slots.slotOf(oldId);
|
||||
if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile)
|
||||
|
||||
// POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the
|
||||
// remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no
|
||||
// slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from
|
||||
// any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds
|
||||
// whenever the occupant exists — which we verified — but routing through it means a
|
||||
// future pool-floor guard added to remove governs replace identically, one rule.
|
||||
const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank);
|
||||
if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed
|
||||
|
||||
// Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling
|
||||
// marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot
|
||||
// places newId there exactly and empties newId's own (source) slot — the slot position
|
||||
// is preserved and only its occupant changed, exactly the replace contract.
|
||||
b->slots.remove(oldId);
|
||||
b->slots.reorder(newId, targetSlot);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankBook::hashReferencedElsewhere(const std::string& hash,
|
||||
const std::string& exceptBankId) const {
|
||||
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
|
||||
@@ -405,6 +610,20 @@ private:
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string SlotMap::serialize() const {
|
||||
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
|
||||
std::string out;
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < entries_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
ObjWriter e(out);
|
||||
e.keyStr("id", entries_[i].id);
|
||||
e.keyRaw("slot", intToStr(entries_[i].slot));
|
||||
}
|
||||
out += ']';
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string BankBook::serialize() const {
|
||||
std::string out;
|
||||
{
|
||||
@@ -425,6 +644,9 @@ std::string BankBook::serialize() const {
|
||||
// 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());
|
||||
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
|
||||
// parser defaults such a bank's slots from insertion order on load.
|
||||
b.keyRaw("slots", banks_[i].slots.serialize());
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (see bank_model note on NRVO + deferred close)
|
||||
@@ -478,6 +700,10 @@ private:
|
||||
bool captureValue(std::string& raw);
|
||||
|
||||
bool parseBank(Bank& out);
|
||||
// Parses the "slots" array ([{id, slot}, ...]) into (id, slot) pairs. An empty
|
||||
// array is valid (an empty bank). Malformed structure fails the whole parse; the
|
||||
// pair-level defensive repair (dupes/conflicts) lives in SlotMap::fromEntries.
|
||||
bool parseSlots(std::vector<std::pair<std::string, int>>& out);
|
||||
};
|
||||
|
||||
bool Parser::parseString(std::string& out) {
|
||||
@@ -654,6 +880,13 @@ bool Parser::parseBank(Bank& b) {
|
||||
if (!idx) return false; // a malformed nested index fails the whole parse
|
||||
b.index = std::move(*idx);
|
||||
haveIndex = true;
|
||||
} else if (key == "slots") {
|
||||
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
|
||||
// nothing because the key never appears); when present it drives the
|
||||
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!parseSlots(pairs)) return false;
|
||||
b.slots = SlotMap::fromEntries(pairs);
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat unknown keys
|
||||
}
|
||||
@@ -665,6 +898,30 @@ bool Parser::parseBank(Bank& b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseSlots(std::vector<std::pair<std::string, int>>& out) {
|
||||
out.clear();
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true; // empty slot array — a bank with no positions yet
|
||||
do {
|
||||
if (!consume('{')) return false;
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool haveId = false, haveSlot = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!parseKey(k)) return false;
|
||||
if (k == "id") { if (!parseString(id)) return false; haveId = true; }
|
||||
else if (k == "slot") { if (!parseInt(slot)) return false; haveSlot = true; }
|
||||
else { if (!skipValue()) return false; } // forward-compat
|
||||
} while (consume(','));
|
||||
if (!consume('}')) return false;
|
||||
if (!haveId || !haveSlot) return false; // a slot entry needs both
|
||||
out.emplace_back(std::move(id), slot);
|
||||
} while (consume(','));
|
||||
return consume(']');
|
||||
}
|
||||
|
||||
bool Parser::parseBook(std::vector<Bank>& banks, std::string& activeBank) {
|
||||
banks.clear();
|
||||
activeBank.clear();
|
||||
|
||||
+136
-1
@@ -39,6 +39,7 @@
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_model.h"
|
||||
@@ -50,6 +51,97 @@ namespace reasampler {
|
||||
inline constexpr const char* kPoolBankId = "pool";
|
||||
inline constexpr const char* kPoolBankName = "Pool";
|
||||
|
||||
// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
|
||||
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
|
||||
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
|
||||
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
|
||||
// first row above an occupied second row). At most one id per slot (a slot is never
|
||||
// double-occupied) and at most one slot per id (an id sits in exactly one place).
|
||||
//
|
||||
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
|
||||
// sample into two banks may sit at different slots, so position is a per-bank display
|
||||
// concern owned by the bank's membership. bank_model / Sample stay untouched.
|
||||
//
|
||||
// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip.
|
||||
class SlotMap {
|
||||
public:
|
||||
// The slot an id occupies, or -1 if the id is not mapped. O(N).
|
||||
int slotOf(const std::string& id) const;
|
||||
|
||||
// The id occupying `slot`, or "" if the slot is empty. O(N).
|
||||
std::string idAt(int slot) const;
|
||||
|
||||
// The highest occupied slot, or -1 when the map is empty. Defines the append
|
||||
// frontier and (with trailing-empty trim) the content extent.
|
||||
int maxSlot() const;
|
||||
|
||||
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
|
||||
// produce no entry — the caller iterates occupants; sparse layout is a draw
|
||||
// concern that reads slotOf/idAt, not this list.
|
||||
std::vector<std::string> orderedIds() const;
|
||||
|
||||
// Places `id` at the next free slot after the last occupied one (append). If the
|
||||
// id is already mapped it is first removed (leaving its old slot empty), then
|
||||
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
|
||||
void append(const std::string& id);
|
||||
|
||||
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
|
||||
// keeps its position. Returns true if the id was mapped.
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
|
||||
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
|
||||
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
|
||||
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
|
||||
// preserving their relative order and never colliding. Matches file-manager
|
||||
// reorder. Interior gaps between shifted occupants are preserved as-is
|
||||
// (shift is +1 on each occupant, so the gap structure above the target is kept).
|
||||
// * negative targetSlot is clamped to 0.
|
||||
// Returns false (no mutation) if `id` is not mapped. Deterministic.
|
||||
bool reorder(const std::string& id, int targetSlot);
|
||||
|
||||
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
|
||||
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
|
||||
// slot data is seeded from its BankIndex insertion order, densely packed (no gaps),
|
||||
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
|
||||
void resetDense(const std::vector<std::string>& ids);
|
||||
|
||||
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
|
||||
// the index) and appends any live id that has NO mapping yet (a sample the index
|
||||
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
|
||||
// the map consistent with the bank's membership without a re-pack. Deterministic:
|
||||
// orphan appends follow `liveIds` order.
|
||||
void reconcile(const std::vector<std::string>& liveIds);
|
||||
|
||||
bool empty() const { return entries_.empty(); }
|
||||
std::size_t size() const { return entries_.size(); }
|
||||
|
||||
bool operator==(const SlotMap& o) const;
|
||||
|
||||
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
|
||||
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
|
||||
// Round-trips losslessly with the rest of the bank.
|
||||
std::string serialize() const;
|
||||
|
||||
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
|
||||
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
|
||||
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
|
||||
// (never double-occupies); an empty id or negative slot is dropped. The result is
|
||||
// sorted ascending by slot. reconcile() against live membership runs afterward, so
|
||||
// a lossy repair here degrades gracefully rather than corrupting lookup.
|
||||
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::string id;
|
||||
int slot = 0;
|
||||
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
|
||||
};
|
||||
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
|
||||
|
||||
void sortBySlot();
|
||||
};
|
||||
|
||||
// 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 {
|
||||
@@ -57,12 +149,13 @@ struct Bank {
|
||||
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
|
||||
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;
|
||||
ordinal == o.ordinal && index == o.index && slots == o.slots;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,6 +290,48 @@ public:
|
||||
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 BankIndex::updateInPlace). Scans banks in
|
||||
|
||||
+9
-1
@@ -41,7 +41,9 @@ bool Sample::operator==(const Sample& o) const {
|
||||
trackGuids == o.trackGuids && wetDry == o.wetDry &&
|
||||
channelCount == o.channelCount && sampleRate == o.sampleRate &&
|
||||
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
|
||||
captureTempo == o.captureTempo && key == o.key && levels == o.levels &&
|
||||
captureTempo == o.captureTempo &&
|
||||
captureTimeSigNum == o.captureTimeSigNum &&
|
||||
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key && levels == o.levels &&
|
||||
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
|
||||
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
|
||||
}
|
||||
@@ -244,6 +246,8 @@ void writeSample(std::string& out, const Sample& s) {
|
||||
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
|
||||
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
|
||||
w.keyRaw("captureTempo", numToStr(s.captureTempo));
|
||||
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
|
||||
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
|
||||
|
||||
// Optionals are emitted as null when absent so present/absent round-trips.
|
||||
w.keyBegin("key");
|
||||
@@ -590,6 +594,10 @@ bool Parser::parseSample(Sample& s) {
|
||||
if (!parseDouble(s.lengthBeats)) return false;
|
||||
} else if (key == "captureTempo") {
|
||||
if (!parseDouble(s.captureTempo)) return false;
|
||||
} else if (key == "captureTimeSigNum") {
|
||||
if (!parseInt(s.captureTimeSigNum)) return false;
|
||||
} else if (key == "captureTimeSigDenom") {
|
||||
if (!parseInt(s.captureTimeSigDenom)) return false;
|
||||
} else if (key == "key") {
|
||||
bool wasNull = false;
|
||||
if (!expectNullOr(wasNull)) return false;
|
||||
|
||||
@@ -86,6 +86,13 @@ struct Sample {
|
||||
double lengthBeats = 0.0;
|
||||
double captureTempo = 0.0; // project tempo (BPM) at capture time
|
||||
|
||||
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the
|
||||
// bars.beats.subdivisions read-out is stable under later project meter changes).
|
||||
// 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter);
|
||||
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms.
|
||||
int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped
|
||||
int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
|
||||
|
||||
std::optional<std::string> key; // musical key, when known
|
||||
|
||||
Levels levels;
|
||||
|
||||
+48
-15
@@ -54,6 +54,7 @@
|
||||
#include "bank_book.h"
|
||||
#include "bank_grid.h"
|
||||
#include "bank_model.h"
|
||||
#include "card_meta.h" // L7 decorative overlay formatters: bars.beats + s.ms (pure)
|
||||
#include "capture_paths.h"
|
||||
#include "component_geometry.h" // KitBox — the kit text()'s draw box (L1)
|
||||
#include "draw_kit.h" // kit text() over cached AA fonts — retires GDI DrawText (L1)
|
||||
@@ -455,35 +456,64 @@ const Envelope& thumbnailFor(const Sample& sample, int width,
|
||||
|
||||
// --- Drawing: thumbnails (unchanged from M5) ----------------------------------
|
||||
|
||||
// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT
|
||||
// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT
|
||||
// (wall-clock). Decorative + non-interactive (no hit-test, no hover). Drawn in the kit's
|
||||
// Micro / ValueMono classes in text/dim, subordinate to the waveform. A blank musical
|
||||
// read-out (unstamped meter / unknown tempo) simply omits the bottom-left string.
|
||||
void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
||||
MusicalLength ml;
|
||||
ml.lengthSeconds = s.lengthSeconds;
|
||||
ml.tempoBpm = s.captureTempo;
|
||||
ml.timeSigNum = s.captureTimeSigNum;
|
||||
ml.timeSigDenom = s.captureTimeSigDenom;
|
||||
const std::string bars = formatBarsBeats(ml); // "" when unstamped/no-tempo
|
||||
const std::string secs = formatSecondsMs(s.lengthSeconds);
|
||||
|
||||
// A short strip along the card's bottom edge. Left/right halves; text/dim so the
|
||||
// waveform stays the centerpiece. Micro on the left (musical), ValueMono on the right
|
||||
// (tabular numbers that must not jitter).
|
||||
const int stripH = 12;
|
||||
const int pad = 3;
|
||||
const int y = rect.y + rect.height - stripH;
|
||||
if (!bars.empty()) {
|
||||
const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH};
|
||||
text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left);
|
||||
}
|
||||
const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH};
|
||||
text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right);
|
||||
}
|
||||
|
||||
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
bool selected, bool focused, bool hovered) {
|
||||
// Cell surface through the kit: Active (accent) when selected, else hover-or-rest bg/cell.
|
||||
// The grid is the centerpiece (bones preserved) — the surface picks up the L2 palette +
|
||||
// micro-gradient while the waveform plot below stays the panel's own draw.
|
||||
bool selected, bool focused, bool hovered, const Sample* sample) {
|
||||
// Cell surface through the kit (L7 selection restyle): a selected card draws the NORMAL
|
||||
// cell surface (Rest, or Hover when hovered) — NOT the inverted accent-fill. Selection is
|
||||
// marked purely by an accent/tertiary (pastel purple) border below; hover stays a fill-
|
||||
// state change orthogonal to that border, so a hovered selected card still reads selected.
|
||||
const KitBox cell{rect.x, rect.y, rect.width, rect.height};
|
||||
const InteractionState state = selected ? InteractionState::Active
|
||||
: (hovered ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
const InteractionState state = hovered ? InteractionState::Hover : InteractionState::Rest;
|
||||
fillSurface(bmp, cell, Role::BgCell, state);
|
||||
|
||||
// Border: accent when selected, else hairline. A focus ring is a distinct text/primary
|
||||
// double-line (the kit's focus convention) so focus reads even on a selected cell.
|
||||
const KitColor border = selected ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline);
|
||||
// Border (L7): accent/TERTIARY purple when selected (the sole selection signal), else
|
||||
// hairline. Focus is a distinct text/primary inner ring so a focused-AND-selected card
|
||||
// reads BOTH — the purple outer border + the inner focus ring — kept visually separate.
|
||||
const KitColor border = selected ? roleColor(Role::AccentTertiary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, toLice(border), 1.0f, 0);
|
||||
if (focused) {
|
||||
const LICE_pixel ring = toLice(roleColor(Role::TextPrimary));
|
||||
LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0);
|
||||
}
|
||||
|
||||
// Waveform plot (peaks invariant: min<=max). The wave uses the accent role except on a
|
||||
// selected cell (whose fill is already the accent) — there it draws in bg/base for contrast.
|
||||
// Waveform plot (peaks invariant: min<=max). The wave keeps its NORMAL accent color in
|
||||
// every state (L7 dropped the inverted bg/base wave on the selected cell — the cell fill
|
||||
// is no longer inverted, so no contrast swap is needed).
|
||||
const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
|
||||
const LICE_pixel waveCol =
|
||||
toLice(selected ? roleColor(Role::BgBase) : roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel waveCol = toLice(roleColor(Role::AccentPrimary));
|
||||
|
||||
if (env.empty()) {
|
||||
const int midY = rect.y + rect.height / 2;
|
||||
LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
|
||||
if (sample) drawCardMeta(bmp, rect, *sample); // L7 overlay even on an empty envelope
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -513,6 +543,9 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
|
||||
}
|
||||
}
|
||||
|
||||
// L7 decorative metadata overlay, drawn last so it sits over the waveform.
|
||||
if (sample) drawCardMeta(bmp, rect, *sample);
|
||||
}
|
||||
|
||||
// --- Kit draw adapters (Phase L) ----------------------------------------------
|
||||
@@ -1266,7 +1299,7 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
|
||||
// Grid-cell hover is intentionally not tracked: the cell already carries selection +
|
||||
// focus chrome (the centerpiece's "bones"); a third transient hover state on every
|
||||
// cell would add repaint churn + visual noise. Hover lights the chrome/buttons/tabs.
|
||||
drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false);
|
||||
drawThumbnail(bmp, rect, env, selected, focused, /*hovered=*/false, &samples[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
#define REAPERAPI_WANT_Main_OnCommand
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
@@ -498,6 +499,21 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
s.sampleRate = effectiveSampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = request.endSeconds - request.startSeconds;
|
||||
s.captureTempo = Master_GetTempo(); // BPM at capture time (verified ~4651)
|
||||
// Time signature at the capture's START time (L7 F1 stamp). TimeMap_GetTimeSigAtTime
|
||||
// (verified reaper_plugin_functions.h:7130 — void(ReaProject*, double time,
|
||||
// int* numOut, int* denomOut, double* tempoOut)) reads the meter effective at that
|
||||
// project time, so a sample captured under 3/4 keeps a 3/4 read-out even if the
|
||||
// project later switches to 4/4. proj=nullptr => the active project (matches the
|
||||
// Master_GetTempo() call above, which is also active-project). The tempoOut is
|
||||
// ignored — captureTempo already carries the master tempo. Leaves 0/0 (unstamped)
|
||||
// if the API is somehow unavailable; the formatter renders a blank musical read-out.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(nullptr, request.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
s.captureTimeSigNum = tsNum;
|
||||
s.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// Content hash: WAV-aware FNV-1a over the rendered file's fmt+data chunks so
|
||||
// hashReferencedElsewhere can identify copies in other banks and suppress the
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_InsertTrackAtIndex
|
||||
#define REAPERAPI_WANT_DeleteTrack
|
||||
@@ -507,6 +508,17 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
||||
? st.request_.sampleRate
|
||||
: static_cast<int>(GetSetProjectInfo(st.proj_, "PROJECT_SRATE", 0.0, false));
|
||||
cap.captureTempo = Master_GetTempo();
|
||||
// Time signature at the record range's START (L7 F1). TimeMap_GetTimeSigAtTime
|
||||
// (reaper_plugin_functions.h:7130) reads the meter effective at that project time;
|
||||
// proj=st.proj_ pins the recording's own project. tempoOut ignored (captureTempo is
|
||||
// the master tempo above). Leaves 0/0 (unstamped) on any failure.
|
||||
{
|
||||
int tsNum = 0, tsDenom = 0;
|
||||
double tsTempo = 0.0;
|
||||
TimeMap_GetTimeSigAtTime(st.proj_, st.request_.startSeconds, &tsNum, &tsDenom, &tsTempo);
|
||||
cap.captureTimeSigNum = tsNum;
|
||||
cap.captureTimeSigDenom = tsDenom;
|
||||
}
|
||||
cap.createdTimestamp = static_cast<std::int64_t>(std::time(nullptr));
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor.
|
||||
|
||||
#include "card_drag.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Half-open point-in-rect (matches drag_out / bank_grid: [x, x+w) x [y, y+h)).
|
||||
bool insideClient(int px, int py, const PanelClientRect& c) {
|
||||
return px >= c.x && px < c.x + c.width &&
|
||||
py >= c.y && py < c.y + c.height;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state, const DragModifiers& mods) {
|
||||
// No drag / empty payload: nothing to do.
|
||||
if (!state.dragging || !state.hasArmedSamples) return CardGesture::None;
|
||||
|
||||
// Precedence 1: pointer left the client rect -> OS drag-out (wins first).
|
||||
if (!insideClient(px, py, client)) return CardGesture::OsDragOut;
|
||||
|
||||
// Precedence 2: over a tab / the other bank -> move (or copy on Ctrl).
|
||||
if (mods.region == DropRegion::OtherBankOrTab)
|
||||
return mods.ctrl ? CardGesture::Copy : CardGesture::Move;
|
||||
|
||||
// Precedence 3: within the same bank's own grid -> reorder / replace.
|
||||
if (mods.region == DropRegion::SameBankGrid) {
|
||||
// Alt over an OCCUPIED slot replaces; otherwise reorder (empty = place,
|
||||
// occupied+no-Alt = insert-before-and-shift).
|
||||
if (mods.alt && mods.slotOccupied) return CardGesture::Replace;
|
||||
return CardGesture::Reorder;
|
||||
}
|
||||
|
||||
// Dead space inside the client: a drop here is a no-op.
|
||||
return CardGesture::None;
|
||||
}
|
||||
|
||||
CursorCue cursorForGesture(CardGesture g) {
|
||||
switch (g) {
|
||||
case CardGesture::OsDragOut: return CursorCue::OsDragOut;
|
||||
case CardGesture::Move: return CursorCue::Move;
|
||||
case CardGesture::Copy: return CursorCue::Copy;
|
||||
case CardGesture::Reorder: return CursorCue::Reorder;
|
||||
case CardGesture::Replace: return CursorCue::Replace;
|
||||
case CardGesture::None: return CursorCue::Default;
|
||||
}
|
||||
return CursorCue::Default;
|
||||
}
|
||||
|
||||
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
|
||||
const GridSpec& spec) {
|
||||
std::vector<SlotCellRect> rects;
|
||||
if (maxSlot < 0) return rects;
|
||||
|
||||
const int cols = columnsForWidth(panelWidth, spec);
|
||||
const int count = maxSlot + 1; // slots 0..maxSlot inclusive (empties included)
|
||||
rects.reserve(static_cast<std::size_t>(count));
|
||||
|
||||
for (int slot = 0; slot < count; ++slot) {
|
||||
const int col = slot % cols;
|
||||
const int row = slot / cols;
|
||||
SlotCellRect r;
|
||||
r.slot = slot;
|
||||
r.x = spec.gap + col * (spec.cellWidth + spec.gap);
|
||||
r.y = spec.gap + row * (spec.cellHeight + spec.gap);
|
||||
r.width = spec.cellWidth;
|
||||
r.height = spec.cellHeight;
|
||||
rects.push_back(r);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) {
|
||||
for (const SlotCellRect& r : rects) {
|
||||
// Half-open bounds so adjacent rects never both claim a pixel.
|
||||
if (px >= r.x && px < r.x + r.width &&
|
||||
py >= r.y && py < r.y + r.height)
|
||||
return r.slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#pragma once
|
||||
// card_drag — the REAPER-free decision logic behind the L7 in-grid reorder drag. Three
|
||||
// pure concerns live here so they are unit-tested outside the DAW (CLAUDE.md §load-bearing
|
||||
// split); the SWELL wiring, SetCursor call, cursor resources, and drop-target draw stay in
|
||||
// the shell (bank_panel.cpp). Mirror of drag_out::decideGesture.
|
||||
//
|
||||
// 1. GESTURE PRECEDENCE (F3 settled). A live drag resolves to exactly one gesture, in a
|
||||
// strict precedence the shell evaluates on every mouse-move / at drop:
|
||||
// (1) pointer LEFT the client rect -> OsDragOut (hand off to the OS)
|
||||
// (2) else drop over a tab / the OTHER bank -> Move | Copy (Ctrl = Copy)
|
||||
// (3) else drop within the SAME bank's grid -> Reorder | Replace
|
||||
// - empty slot -> Reorder (place there)
|
||||
// - occupied slot, no modifier -> Reorder (insert-before-and-shift)
|
||||
// - occupied slot, Alt held -> Replace (Alt-replace-over-occupied)
|
||||
// So leave-client wins first, then other-bank, then same-bank-grid = reorder/replace.
|
||||
// This keeps the reorder gesture from ever stealing a bank-move or OS-drag.
|
||||
//
|
||||
// 2. SLOT HIT-TEST. Which grid SLOT a pointer sits over, sparse-aware: the grid tiles
|
||||
// slots 0..maxSlot including empty ones, so hit-testing maps a point to a slot index
|
||||
// (empty or occupied) or -1 for a miss. The pixel<->slot rect math extends bank_grid's
|
||||
// dense tiling to the gap-preserving slot layout.
|
||||
//
|
||||
// 3. DROP-RESULT -> CURSOR CUE. The resolved gesture maps to a cursor cue enum the shell
|
||||
// turns into a SetCursor call. The cue DECISION is pure (here); the shell owns only
|
||||
// the SetCursor call and the cursor resources. The Replace cue appears ONLY when Alt
|
||||
// is actually held over an occupied slot (precedence rule 3's Alt branch).
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO OS, NO vendor/ includes. Standard
|
||||
// library only. Reuses drag_out's PanelClientRect / DragState and bank_grid's CellRect.
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "bank_grid.h" // CellRect
|
||||
#include "drag_out.h" // PanelClientRect, DragState
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Which drop region the pointer currently sits over WITHIN the client rect. The shell
|
||||
// classifies the live pointer against its own region geometry (tab strip / other bank
|
||||
// region / this bank's own grid) and passes the verdict; card_drag does not know panel
|
||||
// layout, only the precedence over these verdicts. (When the pointer has left the client
|
||||
// rect the shell need not compute this — OsDragOut wins first regardless.)
|
||||
enum class DropRegion {
|
||||
SameBankGrid, // over the dragged samples' OWN bank grid — a reorder/replace target
|
||||
OtherBankOrTab, // over a tab or the other region's bank — a move/copy target
|
||||
DeadSpace, // inside the client but over no drop target (header, footer, gap)
|
||||
};
|
||||
|
||||
// The resolved gesture — one clean outcome the shell acts on and maps to a cursor.
|
||||
enum class CardGesture {
|
||||
None, // no drag under way, or an empty payload — do nothing
|
||||
OsDragOut, // pointer left the client rect — hand off to the native OS drag (drag_out)
|
||||
Move, // drop over another bank/tab, no Ctrl — move the samples there
|
||||
Copy, // drop over another bank/tab, Ctrl held — copy the samples there
|
||||
Reorder, // drop within the same bank grid — reorder to the target slot
|
||||
Replace, // drop within the same bank grid, Alt over an OCCUPIED slot — replace
|
||||
};
|
||||
|
||||
// The live drag inputs the precedence decision needs beyond position + client rect:
|
||||
// region — the shell's verdict on what the pointer sits over (see DropRegion).
|
||||
// targetSlot — the slot the pointer sits over in the same-bank grid, or -1 (used only
|
||||
// when region == SameBankGrid to decide empty-vs-occupied).
|
||||
// slotOccupied — whether targetSlot currently holds a sample (drives Reorder vs Replace).
|
||||
// ctrl — Ctrl held (Copy vs Move over another bank).
|
||||
// alt — Alt held (Replace vs Reorder over an occupied same-bank slot).
|
||||
struct DragModifiers {
|
||||
DropRegion region = DropRegion::DeadSpace;
|
||||
int targetSlot = -1;
|
||||
bool slotOccupied = false;
|
||||
bool ctrl = false;
|
||||
bool alt = false;
|
||||
};
|
||||
|
||||
// Resolves the gesture for a drag at pointer (px, py) over `client`, given the drag
|
||||
// `state` and the live `mods`. Precedence exactly as documented above.
|
||||
// * Not dragging / no armed samples: None.
|
||||
// * Pointer OUTSIDE the client rect: OsDragOut (wins first — invariant #4 boundary).
|
||||
// * OtherBankOrTab: Copy if ctrl else Move.
|
||||
// * SameBankGrid: Replace iff (alt AND the target slot is occupied); else Reorder
|
||||
// (whether the slot is empty — place — or occupied without Alt — insert-shift).
|
||||
// * DeadSpace inside the client: None (a drop here is a no-op).
|
||||
CardGesture decideCardGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state, const DragModifiers& mods);
|
||||
|
||||
// The cursor cue the shell should show for a resolved gesture. 1:1 with CardGesture but
|
||||
// named as a cursor concern so the shell maps it to a SetCursor resource. None -> the
|
||||
// default arrow. The Replace cue is produced ONLY for CardGesture::Replace (which itself
|
||||
// requires Alt-over-occupied), satisfying "the replace cursor appears only while Alt is
|
||||
// held over an occupied slot."
|
||||
enum class CursorCue {
|
||||
Default, // arrow — no drag, or dead space
|
||||
Reorder, // within-bank reorder
|
||||
Move, // move to another bank/tab
|
||||
Copy, // copy to another bank/tab
|
||||
OsDragOut, // pointer left the client (the OS drag loop owns the cursor once handed off)
|
||||
Replace, // Alt-replace over an occupied slot
|
||||
};
|
||||
|
||||
// Maps a resolved gesture to its cursor cue (pure — the shell owns SetCursor only).
|
||||
CursorCue cursorForGesture(CardGesture g);
|
||||
|
||||
// --- Sparse-aware slot layout + hit-test --------------------------------------
|
||||
|
||||
// The pixel rect of one grid SLOT (empty or occupied). Distinct from bank_grid's CellRect
|
||||
// only in intent — a SlotCellRect carries the slot index it draws, so the shell can map a
|
||||
// drawn/hit rect back to the model slot without a parallel array. width/height match the
|
||||
// grid spec; (x, y) is the top-left in the region's grid-viewport coordinates (the shell
|
||||
// translates by the grid origin exactly as regionCellRects does today).
|
||||
struct SlotCellRect {
|
||||
int slot = 0; // the model slot this rect represents (0..maxSlot)
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const SlotCellRect& o) const {
|
||||
return slot == o.slot && x == o.x && y == o.y &&
|
||||
width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// Tiles slots 0..maxSlot (INCLUSIVE) into a panel of the given pixel width, honoring the
|
||||
// grid spec — the sparse-aware sibling of bank_grid::computeCellRects. Every slot in
|
||||
// [0, maxSlot] gets a rect (empty slots included) so a gap draws as an empty cell and a
|
||||
// drop targets it precisely. `maxSlot` < 0 -> empty (no occupied slots). The rects use the
|
||||
// SAME column/row math as computeCellRects (slot index in place of item index), so an
|
||||
// all-dense map (slots 0..N-1) lays out identically to today's grid.
|
||||
std::vector<SlotCellRect> computeSlotRects(int maxSlot, int panelWidth,
|
||||
const GridSpec& spec);
|
||||
|
||||
// Hit-tests a point against slot rects (half-open bounds, matching hitTestCell). Returns
|
||||
// the SLOT index (rect.slot) of the first rect containing the point, or -1 on a miss (gap,
|
||||
// margin, below the last row). NOTE the return is the slot index, NOT the vector index —
|
||||
// callers reason in model slots.
|
||||
int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,64 @@
|
||||
// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor.
|
||||
|
||||
#include "card_meta.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
std::string formatBarsBeats(const MusicalLength& m) {
|
||||
// No derivable musical read-out without a positive tempo AND a stamped meter.
|
||||
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
|
||||
|
||||
const double len = m.lengthSeconds > 0.0 ? m.lengthSeconds : 0.0;
|
||||
|
||||
// Total beats in THIS meter. A quarter-note is 60/tempo s; a beat is (4/denom)
|
||||
// quarter-notes, so a beat lasts (60/tempo) * (4/denom) seconds. beats = len / that.
|
||||
const double secondsPerBeat = (60.0 / m.tempoBpm) * (4.0 / m.timeSigDenom);
|
||||
double totalBeats = len / secondsPerBeat;
|
||||
|
||||
// Snap to an exact beat when we are within a hundredth-of-a-beat epsilon of one, so a
|
||||
// bar-aligned capture reads "2.1.00" rather than "1.4.99" from FP error just under the
|
||||
// boundary. The epsilon is well below the .01 display quantum, so it never mis-rounds a
|
||||
// genuinely fractional length.
|
||||
const double snapped = std::floor(totalBeats + 0.5);
|
||||
if (std::fabs(totalBeats - snapped) < 1e-6) totalBeats = snapped;
|
||||
|
||||
// Split into whole beats + a fractional remainder (0..1 of a beat).
|
||||
double wholeBeats = std::floor(totalBeats);
|
||||
double frac = totalBeats - wholeBeats;
|
||||
|
||||
// Bars/beats are 1-based; beat cycles 1..timeSigNum within a bar.
|
||||
const long wb = static_cast<long>(wholeBeats);
|
||||
const long bar = wb / m.timeSigNum + 1; // 1-based bar
|
||||
const long beat = wb % m.timeSigNum + 1; // 1-based beat within the bar
|
||||
|
||||
// Subdivision: hundredths of a beat, floored (0..99). A decorative display quantum.
|
||||
int sub = static_cast<int>(std::floor(frac * 100.0));
|
||||
if (sub < 0) sub = 0;
|
||||
if (sub > 99) sub = 99;
|
||||
|
||||
char buf[48];
|
||||
std::snprintf(buf, sizeof(buf), "%ld.%ld.%02d", bar, beat, sub);
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string formatSecondsMs(double lengthSeconds) {
|
||||
double len = lengthSeconds > 0.0 ? lengthSeconds : 0.0;
|
||||
|
||||
long secs = static_cast<long>(std::floor(len));
|
||||
// Round to the nearest millisecond (not floor): FP error means 62.037 s stores as
|
||||
// 62.0369999... and a raw floor would render "62.036". +0.5 before truncation rounds
|
||||
// to the closest ms, which is what a wall-clock read-out should show.
|
||||
int ms = static_cast<int>((len - static_cast<double>(secs)) * 1000.0 + 0.5);
|
||||
// Rounding can push ms to 1000 at a whole-second boundary; carry into seconds.
|
||||
if (ms >= 1000) { ms -= 1000; ++secs; }
|
||||
if (ms < 0) ms = 0;
|
||||
|
||||
char buf[48];
|
||||
std::snprintf(buf, sizeof(buf), "%ld.%03d", secs, ms);
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
// card_meta — pure formatting for the L7 decorative card metadata overlay. Each bank
|
||||
// card overlays capture length as bars.beats.subdivisions (bottom-LEFT, musical) and
|
||||
// seconds.milliseconds (bottom-RIGHT, wall-clock). Both read-outs are DECORATIVE and
|
||||
// non-interactive; the bank_panel draws them via the L1 kit. The formatting itself is
|
||||
// pure string work over the sample's stamped tempo + meter + length, so it is
|
||||
// unit-tested outside the DAW (CLAUDE.md §load-bearing split).
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO LICE, NO vendor/ includes. Standard
|
||||
// library only. Mirror of tooltip's prefix-strip helper.
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The musical length inputs, taken straight off a Sample (L7 F1 capture-time stamp):
|
||||
// lengthSeconds — captured length in wall-clock seconds (>= 0).
|
||||
// tempoBpm — project tempo (BPM) at capture (Sample.captureTempo); 0 = unknown.
|
||||
// timeSigNum — meter numerator at capture (Sample.captureTimeSigNum); 0 = unstamped.
|
||||
// timeSigDenom — meter denominator at capture (Sample.captureTimeSigDenom); 0 = unstamped.
|
||||
struct MusicalLength {
|
||||
double lengthSeconds = 0.0;
|
||||
double tempoBpm = 0.0;
|
||||
int timeSigNum = 0;
|
||||
int timeSigDenom = 0;
|
||||
};
|
||||
|
||||
// bars.beats.subdivisions from a capture-time tempo + meter stamp (musical read-out).
|
||||
//
|
||||
// Derivation: one quarter-note lasts 60 / tempo seconds; a beat in this meter lasts
|
||||
// (4 / timeSigDenom) quarter-notes; a bar holds timeSigNum beats. From lengthSeconds we
|
||||
// get total beats, split into whole bars (÷ timeSigNum) + whole leftover beats + a
|
||||
// subdivision remainder scaled to 1..N of the next beat. The output is 1-BASED and
|
||||
// zero-padded to two subdivision digits: "1.1.00" is exactly one bar-start (a
|
||||
// zero-length or bar-aligned capture), "2.3.50" is 1 bar + 2 beats + half a beat.
|
||||
//
|
||||
// Contract / edge cases (all tested):
|
||||
// * UNSTAMPED meter (timeSigNum <= 0 || timeSigDenom <= 0) OR unknown tempo
|
||||
// (tempoBpm <= 0): returns "" — no musical read-out is derivable (the caller keeps
|
||||
// the s.ms read-out). This is the pre-L7-sample fallback (blank musical read-out).
|
||||
// * zero length: "1.1.00" (bar 1, beat 1, no subdivision) — the musical origin.
|
||||
// * exact bar boundary: the beat rolls to 1 and the bar increments (never "1.5.00"
|
||||
// in 4/4 — that reads as "2.1.00").
|
||||
// * long captures: bars grow without cap ("129.1.00" is fine).
|
||||
// The subdivision is 0..99 (hundredths of a beat), floored — a display quantum, not a
|
||||
// tick-accurate PPQ (the model refuses to invent PPQ; this is a decorative read-out).
|
||||
std::string formatBarsBeats(const MusicalLength& m);
|
||||
|
||||
// seconds.milliseconds from a wall-clock length (always derivable, meter-independent).
|
||||
// * "S.mmm" — integer seconds, a dot, zero-padded 3-digit milliseconds (floored).
|
||||
// e.g. 0.0 -> "0.000", 1.5 -> "1.500", 62.037 -> "62.037".
|
||||
// * negative length is clamped to "0.000" (a length is never negative; defensive).
|
||||
std::string formatSecondsMs(double lengthSeconds);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -1240,6 +1240,8 @@ static void RunRecaptureFromSource()
|
||||
updated.sampleRate = res.sample.sampleRate;
|
||||
updated.lengthSeconds = res.sample.lengthSeconds;
|
||||
updated.captureTempo = res.sample.captureTempo;
|
||||
updated.captureTimeSigNum = res.sample.captureTimeSigNum; // L7 F1: refresh meter stamp
|
||||
updated.captureTimeSigDenom = res.sample.captureTimeSigDenom; // to the re-capture's meter
|
||||
updated.trackGuids = res.sample.trackGuids;
|
||||
updated.createdTimestamp = res.sample.createdTimestamp;
|
||||
// NOTE: levels, clipped, and lengthBeats are carried from the original (via the
|
||||
|
||||
@@ -597,6 +597,14 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
|
||||
}
|
||||
|
||||
// L7 slot migration: seed every bank's display-position SlotMap from its index
|
||||
// insertion order when the loaded blob carried none (a pre-L7 project -> dense,
|
||||
// gap-free, visually identical on first post-L7 load), and reconcile a partial map
|
||||
// (drop stale markers, append unmapped samples) for a blob written by an earlier L7
|
||||
// build. One-way: once the book is re-saved the reconciled slot data is authoritative.
|
||||
// Idempotent, so a fresh empty book is a cheap no-op.
|
||||
book_.reconcileSlots();
|
||||
|
||||
// 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
|
||||
|
||||
@@ -51,6 +51,8 @@ Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
||||
s.captureTempo = cap.captureTempo;
|
||||
s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped)
|
||||
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// contentHash set by the caller (capture_realtime.cpp) after the file is
|
||||
// finalized and on disk — the hash is over the finished file bytes. Left empty
|
||||
|
||||
@@ -109,6 +109,10 @@ struct RecordedCapture {
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
|
||||
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
|
||||
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
|
||||
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
|
||||
int captureTimeSigNum = 0;
|
||||
int captureTimeSigDenom = 0;
|
||||
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user