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();
|
||||
|
||||
Reference in New Issue
Block a user