diff --git a/CMakeLists.txt b/CMakeLists.txt index c2c8b02..8392ba8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -421,6 +421,31 @@ target_link_libraries(mode_enable PUBLIC view_mode_model) add_library(tooltip STATIC src/tooltip.cpp) target_include_directories(tooltip PUBLIC src) +# --------------------------------------------------------------------------- +# 2t) Pure card_meta formatters — NO REAPER, NO SWELL, NO LICE. The Phase L (L7) +# decorative card metadata overlay's formatting: bars.beats.subdivisions from a +# capture-time tempo + meter stamp (F1) and seconds.milliseconds from length. Split +# out so the musical/wall-clock string derivation (with its bar-boundary + unstamped +# edge cases) is unit-tested outside the DAW; the bank_panel kit-text overlay draw is +# DAW-verified. Mirror of tooltip's prefix-strip helper — pure, CTest-covered. +# --------------------------------------------------------------------------- +add_library(card_meta STATIC src/card_meta.cpp) +target_include_directories(card_meta PUBLIC src) + +# --------------------------------------------------------------------------- +# 2u) Pure card_drag gesture — NO REAPER, NO SWELL, NO LICE, NO OS. The Phase L (L7) +# in-grid reorder drag decision logic: the F3 gesture precedence (leave-client -> +# OS-drag; else other-bank -> move/copy; else same-bank grid -> reorder/replace), +# the resolved-gesture -> cursor-cue map, and the sparse-aware slot rect layout + +# point -> slot hit-test (empties included). Split out so the precedence + slot math +# is unit-tested outside the DAW; the SWELL wiring + SetCursor call + drop-target draw +# are DAW-verified. Reuses drag_out's PanelClientRect/DragState + bank_grid's CellRect/ +# GridSpec. Mirror of drag_out::decideGesture / bank_grid. +# --------------------------------------------------------------------------- +add_library(card_drag STATIC src/card_drag.cpp) +target_include_directories(card_drag PUBLIC src) +target_link_libraries(card_drag PUBLIC drag_out bank_grid) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -551,6 +576,14 @@ add_executable(tooltip_tests tests/test_tooltip.cpp) target_link_libraries(tooltip_tests PRIVATE tooltip) add_test(NAME tooltip_tests COMMAND tooltip_tests) +add_executable(card_meta_tests tests/test_card_meta.cpp) +target_link_libraries(card_meta_tests PRIVATE card_meta) +add_test(NAME card_meta_tests COMMAND card_meta_tests) + +add_executable(card_drag_tests tests/test_card_drag.cpp) +target_link_libraries(card_drag_tests PRIVATE card_drag) +add_test(NAME card_drag_tests COMMAND card_drag_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -599,8 +632,10 @@ add_library(reaper_reasampler MODULE src/overflow_menu.cpp src/mode_enable.cpp src/tooltip.cpp + src/card_meta.cpp + src/card_drag.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' diff --git a/src/bank_book.cpp b/src/bank_book.cpp index 56986ee..786e265 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -18,6 +18,128 @@ 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 SlotMap::orderedIds() const { + // entries_ is sorted ascending by slot, so a straight walk is display order. + std::vector 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; + if (slotOf(id) == targetSlot) return false; // already there — true no-op + + // 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& 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& 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>& 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 +416,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 +438,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 indexIds(const BankIndex& idx) { + std::vector 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 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 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 +611,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 +645,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 +701,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>& out); }; bool Parser::parseString(std::string& out) { @@ -654,6 +881,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> pairs; + if (!parseSlots(pairs)) return false; + b.slots = SlotMap::fromEntries(pairs); } else { if (!skipValue()) return false; // forward-compat unknown keys } @@ -665,6 +899,30 @@ bool Parser::parseBank(Bank& b) { return true; } +bool Parser::parseSlots(std::vector>& 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& banks, std::string& activeBank) { banks.clear(); activeBank.clear(); diff --git a/src/bank_book.h b/src/bank_book.h index d18af52..9b4c6f8 100644 --- a/src/bank_book.h +++ b/src/bank_book.h @@ -39,6 +39,7 @@ #include #include +#include #include #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 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& 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& 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>& 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 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 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 diff --git a/src/bank_model.cpp b/src/bank_model.cpp index 3c2b768..e451219 100644 --- a/src/bank_model.cpp +++ b/src/bank_model.cpp @@ -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; diff --git a/src/bank_model.h b/src/bank_model.h index 2bb59ad..19ce437 100644 --- a/src/bank_model.h +++ b/src/bank_model.h @@ -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 key; // musical key, when known Levels levels; diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 922f58c..99bc4f2 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -54,6 +54,8 @@ #include "bank_book.h" #include "bank_grid.h" #include "bank_model.h" +#include "card_drag.h" // L7 pure gesture precedence + sparse slot layout/hit-test +#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) @@ -303,9 +305,20 @@ struct PanelState { Region dragSourceRegion = Region::Pool; std::string dragSourceBankId; // the bank the dragged samples come from std::vector dragSampleIds;// snapshot of the selection at drag start + std::string dragPrimaryId; // the single card grabbed (the focus) — the L7 + // reorder/replace subject (see onLBtnUp dispatch) DropKind dropKind = DropKind::None; // live drop target under the pointer std::string dropBankId; // destination bank id when dropKind==Tab + // --- L7 in-grid reorder/replace drag -------------------------------------- + // The live card gesture resolved by the pure card_drag::decideCardGesture each mouse- + // move (drives the cursor cue AND the drop dispatch), plus the same-bank target slot the + // pointer sits over (>= 0 only for a Reorder/Replace over the source bank's own grid; -1 + // otherwise). A Reorder highlights dragTargetSlot's cell; Replace + a live cursor cue + // signal the Alt-over-occupied case. Reset with the rest of the drag state on drop/cancel. + CardGesture cardGesture = CardGesture::None; + int dragTargetSlot = -1; + // --- Tail-mode toggle ----------------------------------------------------- // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, @@ -455,35 +468,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 +555,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) ---------------------------------------------- @@ -1223,18 +1268,82 @@ RECT createBtnRect(const RECT& region) { return rc; } -// The cell rects for a region's grid, translated into the region's grid viewport. -// Both paint and hit-testing call this. Empty when the index is null/empty. -std::vector regionCellRects(const RECT& region, bool isBanks, - const BankIndex* index) { - if (!index || index->empty()) return {}; +// --- L7 slot-order display bridge --------------------------------------------- +// +// L7 re-maps cell index <-> sample identity: the grid draws in the bank's persisted +// SlotMap order (sparse, gap-preserving), NOT BankIndex insertion order. This one helper +// is the single place that resolves a region's display, composed purely from bank_book's +// slot order (orderedSampleIds) + card_drag's sparse slot rects (computeSlotRects) — the +// shell adds no layout math of its own. +// +// TWO INDEX SPACES the whole panel must keep straight: +// * SLOT — a display position 0..maxSlot; gaps are empty slots that draw as empty +// cells and are valid drop targets. This is what pixels/hit-tests speak. +// * SELECTION — the DENSE occupied-ordinal [0, occupied) space the pure Selection / +// applyClick / navigate reason in. Selection index i <-> orderedIds[i]. +// Keyboard navigation therefore traverses ONLY occupied cells and SKIPS +// gaps (spec: skip-vs-land-on-gap is unspecified -> skip, documented here). +// RegionDisplay carries both plus the translation between them, resolved FRESH each call +// (never cached across a mutation, per the reference-invalidation guardrail). +struct RegionDisplay { + std::vector orderedIds; // occupied ids in slot order (selection space) + std::vector slotRects; // one rect per slot 0..maxSlot, viewport coords + const Bank* bank = nullptr; + + // The id occupying `slot`, or "" for an empty slot / out of range. + std::string idAtSlot(int slot) const { + return bank ? bank->slots.idAt(slot) : std::string{}; + } + // The slot a selection ordinal `sel` maps to, or -1. orderedIds[sel] -> its slot. + int slotForSelection(int sel) const { + if (sel < 0 || sel >= static_cast(orderedIds.size()) || !bank) return -1; + return bank->slots.slotOf(orderedIds[static_cast(sel)]); + } + // The selection ordinal for `slot` (index of its occupant in orderedIds), or -1 when + // the slot is empty. Inverse of slotForSelection. + int selectionForSlot(int slot) const { + const std::string id = idAtSlot(slot); + if (id.empty()) return -1; + for (std::size_t i = 0; i < orderedIds.size(); ++i) + if (orderedIds[i] == id) return static_cast(i); + return -1; + } + int occupiedCount() const { return static_cast(orderedIds.size()); } +}; + +// Resolves a region's display for the currently-shown bank. Empty (no bank / no width) +// yields an empty display. orderedSampleIds reconciles the bank's SlotMap against live +// membership, so a freshly-migrated or out-of-band-mutated bank always yields a complete +// order (trailing empties are trimmed by the model — maxSlot walks only live occupants). +RegionDisplay regionDisplay(const RECT& region, bool isBanks, Region reg) { + RegionDisplay d; + BankBook* b = book(); + if (!b) return d; + const std::string bankId = bankIdForRegion(reg); + if (bankId.empty()) return d; + d.bank = b->bank(bankId); + if (!d.bank) return d; + + d.orderedIds = b->orderedSampleIds(bankId); // occupied ids, slot order (reconciles) + if (d.orderedIds.empty()) return d; + const RECT grid = regionGridRect(region, isBanks); const int w = grid.right - grid.left; - if (w <= 0) return {}; - std::vector rects = - computeCellRects(static_cast(index->size()), w, kGrid); - for (CellRect& r : rects) { r.x += grid.left; r.y += grid.top; } - return rects; + if (w <= 0) return d; + d.slotRects = computeSlotRects(d.bank->slots.maxSlot(), w, kGrid); + for (SlotCellRect& r : d.slotRects) { r.x += grid.left; r.y += grid.top; } + return d; +} + +// The FOCUSED region's display (the slot-order bridge for the region holding the live +// selection). Mirrors columnsForRegion's client read. +RegionDisplay focusedDisplay() { + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const bool isBanks = g_panel.focusedRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + return regionDisplay(region, isBanks, g_panel.focusedRegion); } // --- Drawing: a grid region --------------------------------------------------- @@ -1244,7 +1353,7 @@ std::vector regionCellRects(const RECT& region, bool isBanks, // its cells show selection/focus chrome; the other region draws plain. void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, const BankIndex* index, const std::string& emptyMsg, - bool selectionOwner, const std::string& projectDir) { + bool selectionOwner, const std::string& projectDir, Region reg) { const RECT grid = regionGridRect(region, isBanks); if (grid.bottom <= grid.top) return; @@ -1253,20 +1362,63 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, return; } - const std::vector& samples = index->all(); - const std::vector rects = regionCellRects(region, isBanks, index); + // L7: iterate the bank's SPARSE slot layout (slot order, gaps included), not the dense + // BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection + // space); a slot maps back to its ordinal via selectionForSlot. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); const int binWidth = kGrid.cellWidth - 4; - for (std::size_t i = 0; i < rects.size(); ++i) { - const CellRect& rect = rects[i]; - if (rect.y >= grid.bottom) continue; // below the viewport: skip (no scroll) - const int idx = static_cast(i); - const bool selected = selectionOwner && g_panel.selection.contains(idx); - const bool focused = selectionOwner && g_panel.selection.focus == idx; - const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir); + for (const SlotCellRect& r : disp.slotRects) { + if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll) + const CellRect rect{r.x, r.y, r.width, r.height}; + const std::string id = disp.idAtSlot(r.slot); + if (id.empty()) { + // Interior gap slot: a subtle empty-slot treatment through the kit — a hairline + // outline on bg/cell, clearly NOT a card (decorative, per the L7 spec). No + // selection/focus/waveform, and not a hover or hit target (the grid never tracks + // cell hover; a click on an empty slot clears selection like any grid miss). + fillSurface(bmp, KitBox{rect.x, rect.y, rect.width, rect.height}, + Role::BgCell, InteractionState::Rest); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, + toLice(roleColor(Role::LineHairline)), 1.0f, 0); + continue; + } + const Sample* s = index->query(id); + if (!s) continue; // reconciled order should never name a stale id; defensive + const int sel = disp.selectionForSlot(r.slot); + const bool selected = selectionOwner && sel >= 0 && g_panel.selection.contains(sel); + const bool focused = selectionOwner && sel >= 0 && g_panel.selection.focus == sel; + const Envelope& env = thumbnailFor(*s, binWidth, projectDir); // 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, s); + } +} + +// L7: draws the per-slot reorder/replace drop-target highlight on the target slot's cell, but +// ONLY when a same-bank in-grid drag (Reorder or Replace) is live over THIS region (the drag +// source region). An accent/HOT outline (distinct from the accent/tertiary purple selection +// border, per the spec's "must not be confusable" constraint); Replace draws a doubled outline +// so an Alt-over-occupied replace reads as a stronger "swap" cue than a plain reorder. No-op +// for a move/copy/OS drag or when the pointer is off any slot (dragTargetSlot < 0). +void drawCardDropTarget(LICE_IBitmap* bmp, const RECT& region, bool isBanks, Region reg) { + if (!g_panel.dragging) return; + if (g_panel.cardGesture != CardGesture::Reorder && + g_panel.cardGesture != CardGesture::Replace) + return; + if (g_panel.dragSourceRegion != reg) return; // highlight only the source bank's grid + if (g_panel.dragTargetSlot < 0) return; + + const RECT grid = regionGridRect(region, isBanks); + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + for (const SlotCellRect& r : disp.slotRects) { + if (r.slot != g_panel.dragTargetSlot) continue; + if (r.y >= grid.bottom) return; // below the viewport (no scroll) + const LICE_pixel hot = toLice(roleColor(Role::AccentHot)); + LICE_DrawRect(bmp, r.x, r.y, r.width, r.height, hot, 1.0f, 0); + if (g_panel.cardGesture == CardGesture::Replace) + LICE_DrawRect(bmp, r.x + 1, r.y + 1, r.width - 2, r.height - 2, hot, 1.0f, 0); + return; } } @@ -1417,14 +1569,22 @@ void paintPanel(HWND hwnd, HDC hdc) { drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), "No samples in the pool yet. Capture one to see it here.", - g_panel.focusedRegion == Region::Pool, projectDir); - // Drop-target highlight for the pool region during a drag. - if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion) { + g_panel.focusedRegion == Region::Pool, projectDir, Region::Pool); + // Drop-target highlight for the pool region during a MOVE/COPY drag (a whole-grid + // outline signalling "drop here to move/copy into this bank"). Suppressed for a + // same-bank reorder (that shows a per-SLOT highlight below, not the whole grid). + if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { const RECT grid = regionGridRect(region, false); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } + // L7 per-slot reorder/replace target highlight (source = pool). An accent/hot outline + // on the target slot's cell — distinct from the accent/tertiary purple selection + // border, so it is never confusable with a selected card. + drawCardDropTarget(&bmp, region, /*isBanks=*/false, Region::Pool); } // Split divider. @@ -1452,16 +1612,20 @@ void paintPanel(HWND hwnd, HDC hdc) { g_panel.shownBankId.empty() ? "Select or create a named bank." : "This bank is empty. Move samples here from the pool.", - g_panel.focusedRegion == Region::Banks, projectDir); + g_panel.focusedRegion == Region::Banks, projectDir, Region::Banks); // Drop-target highlight for the banks region during a drag. BanksRegion fires // when the pointer is in the grid but not on a specific tab; Tab draws its own // highlight on the individual tab (drawTabStrip above handles that case). - if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion) { + if (g_panel.dragging && g_panel.dropKind == DropKind::BanksRegion && + (g_panel.cardGesture == CardGesture::Move || + g_panel.cardGesture == CardGesture::Copy)) { const RECT grid = regionGridRect(region, true); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, toLice(roleColor(Role::AccentHot)), 1.0f, 0); } + // L7 per-slot reorder/replace target highlight (source = banks region). + drawCardDropTarget(&bmp, region, /*isBanks=*/true, Region::Banks); } // L4 three-zone chrome + L5 refinements: TOP toolbar (frequent capture + placement) tiles @@ -1715,17 +1879,21 @@ void deinitPreview() { g_panel.previewInited = false; } -// Auditions sample `idx` of the FOCUSED region's displayed bank. +// Auditions the sample at selection ordinal `idx` of the FOCUSED region's displayed bank. +// L7: `idx` is a DISPLAY-order (slot) ordinal, resolved through orderedIds, not a raw +// BankIndex position. void startAudition(int idx) { stopAudition(); const BankIndex* index = indexForRegion(g_panel.focusedRegion); if (!index) return; - const std::vector& samples = index->all(); - if (idx < 0 || idx >= static_cast(samples.size())) return; + const RegionDisplay disp = focusedDisplay(); + if (idx < 0 || idx >= disp.occupiedCount()) return; + const Sample* s = index->query(disp.orderedIds[static_cast(idx)]); + if (!s) return; const std::string projectDir = currentProjectDir(); - const std::string abs = resolveBankFile(projectDir, samples[idx].relativePath); + const std::string abs = resolveBankFile(projectDir, s->relativePath); if (abs.empty()) return; PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); @@ -1753,12 +1921,17 @@ void startAudition(int idx) { bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } +bool altDown() { return (GetAsyncKeyState(VK_MENU) & 0x8000) != 0; } // Alt = replace modifier (L7) void invalidatePanel() { if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } -// The item count of the focused region's bank (0 when none). +// The item count the SELECTION reasons over — the focused region's occupied-cell count. +// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not +// selectable). Occupied count == index size by construction: every index member maps to +// exactly one occupied slot (gaps are empty slots, which the index never backs), so the +// raw index size IS the dense selection-space extent. int focusedItemCount() { const BankIndex* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; @@ -1969,13 +2142,13 @@ void removeSamples(const std::vector& sampleIds, // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { + // L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order. + // orderedIds[i] is the id at selection ordinal i. std::vector ids; - const BankIndex* idx = indexForRegion(g_panel.focusedRegion); - if (!idx) return ids; - const std::vector& samples = idx->all(); - const int count = static_cast(samples.size()); + const RegionDisplay disp = focusedDisplay(); + const int count = disp.occupiedCount(); for (int i : g_panel.selection.indices) - if (i >= 0 && i < count) ids.push_back(samples[static_cast(i)].id); + if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast(i)]); return ids; } @@ -2310,10 +2483,14 @@ void handleClick(int x, int y) { if (!regionAt(x, y, reg)) return; const bool isBanks = reg == Region::Banks; const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); - const BankIndex* index = indexForRegion(reg); - const std::vector rects = regionCellRects(region, isBanks, index); - const int hit = hitTestCell(x, y, rects); - const int count = index ? static_cast(index->size()) : 0; + // L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An + // empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a + // click on a gap clears selection, exactly like a click in the margin) — empty slots + // are decorative, not selectable. + const RegionDisplay disp = regionDisplay(region, isBanks, reg); + const int hitSlot = hitTestSlot(x, y, disp.slotRects); + const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot); + const int count = disp.occupiedCount(); // Switching focus region reseeds the selection there. if (g_panel.focusedRegion != reg) { @@ -2524,6 +2701,84 @@ void updateDropTarget(int x, int y) { } } +// The destination bank id under the current drop target (pool id for PoolRegion; the tab/ +// shown-bank id for Tab/BanksRegion; "" for no target). Derived from updateDropTarget's +// dropKind/dropBankId — the single source of "what bank is under the pointer". +std::string dropTargetBankId() { + switch (g_panel.dropKind) { + case DropKind::PoolRegion: return std::string(kPoolBankId); + case DropKind::Tab: + case DropKind::BanksRegion: return g_panel.dropBankId; + case DropKind::None: return {}; + } + return {}; +} + +// L7: classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) +// the target slot, updating g_panel.cardGesture / dragTargetSlot. Call AFTER updateDropTarget +// so dropKind/dropBankId are current. The pure card_drag::decideCardGesture owns the +// precedence (leave-client -> OS; other-bank -> move/copy; same-bank grid -> reorder/replace); +// the shell only supplies the region verdict, the same-bank target slot + occupancy, and the +// live modifier state. The OS-drag-out boundary is handled by the existing decideGesture path +// in onMouseMove BEFORE this runs, so here the pointer is always inside the client. +void classifyCardDrag(int x, int y) { + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const PanelClientRect client{cr.left, cr.top, w, h}; + + const std::string destBank = dropTargetBankId(); + DragModifiers mods; + mods.ctrl = ctrlDown(); + mods.alt = altDown(); + + if (!destBank.empty() && destBank == g_panel.dragSourceBankId) { + // Same-bank grid: a reorder/replace target. Resolve the slot the pointer sits over + // in the SOURCE bank's own region display + whether it is occupied. + mods.region = DropRegion::SameBankGrid; + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const int slot = hitTestSlot(x, y, disp.slotRects); + mods.targetSlot = slot; + mods.slotOccupied = slot >= 0 && !disp.idAtSlot(slot).empty(); + g_panel.dragTargetSlot = slot; + } else if (!destBank.empty()) { + mods.region = DropRegion::OtherBankOrTab; // move/copy to a different bank/tab + } else { + mods.region = DropRegion::DeadSpace; // header/footer/gap — a no-op drop + } + + const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; + g_panel.cardGesture = decideCardGesture(x, y, client, st, mods); +} + +// Maps the pure L7 cursor cue to a SWELL stock cursor and sets it. The cue DECISION is pure +// (card_drag::cursorForGesture); the shell owns only this SetCursor call + the resource choice. +// Stock SWELL cursors (vendor/WDL/WDL/swell/swell-types.h:1320-1329, mirroring the Win32 OCR_* +// set): Reorder -> IDC_SIZEALL (four-way move, the file-manager reorder idiom); Move -> +// IDC_HAND (grab-and-place to another bank/tab); Copy -> IDC_UPARROW (no stock copy cursor +// exists cross-platform — this is the closest distinct stock cue; a bespoke copy cursor would +// need a resource file, deliberately NOT added); Replace -> IDC_SIZEWE (a distinct "swap +// occupant" cue, shown ONLY when the pure result is Replace, i.e. Alt over an occupied slot); +// OsDragOut -> the OS drag loop owns the cursor once handed off, so leave it (arrow here is +// never seen — the handoff happens before this runs); Default/None -> IDC_ARROW. +void applyDragCursor(CardGesture g) { + const char* idc = IDC_ARROW; + switch (cursorForGesture(g)) { + case CursorCue::Reorder: idc = IDC_SIZEALL; break; + case CursorCue::Move: idc = IDC_HAND; break; + case CursorCue::Copy: idc = IDC_UPARROW; break; + case CursorCue::Replace: idc = IDC_SIZEWE; break; + case CursorCue::OsDragOut: return; // OS drag owns the cursor; do not fight it + case CursorCue::Default: idc = IDC_ARROW; break; + } + SetCursor(LoadCursor(nullptr, idc)); +} + // Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, mirroring // handleClick's precedence exactly (so the element that lights on hover is the one a click // would hit). Returns HoverKind::None for the grid / dead space / a point outside the client @@ -2625,6 +2880,16 @@ void onMouseMove(int x, int y) { g_panel.dragging = true; g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); + // The single card actually grabbed = the focus ordinal's id. This is the L7 + // in-grid reorder/replace subject (see onLBtnUp) — "drag a card" is a single-card + // gesture, distinct from the multi-select move/copy payload in dragSampleIds. + { + const RegionDisplay disp = focusedDisplay(); + const int f = g_panel.selection.focus; + g_panel.dragPrimaryId = + (f >= 0 && f < disp.occupiedCount()) + ? disp.orderedIds[static_cast(f)] : std::string{}; + } g_panel.hovered = Hover{}; // clear hover — the drag owns the visual feedback now g_panel.tooltipShown = false; // a drag never shows a tooltip SetCapture(g_panel.hwnd); @@ -2655,6 +2920,9 @@ void onMouseMove(int x, int y) { g_panel.dragging = false; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); invalidatePanel(); // Empty path list -> nothing draggable (all stale/missing); do not start a drag. @@ -2662,28 +2930,79 @@ void onMouseMove(int x, int y) { initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows return; } + // Inside the client: classify the in-grid gesture (L7 reorder/replace vs the existing + // move/copy) and reflect it as a cursor cue. updateDropTarget first so dropKind/ + // dropBankId are current for classifyCardDrag's same-vs-other-bank decision. updateDropTarget(x, y); + classifyCardDrag(x, y); + applyDragCursor(g_panel.cardGesture); invalidatePanel(); } } -// Commits (or abandons) a drag on button-up. A drop onto a DIFFERENT bank moves the -// dragged samples there; a drop onto the source bank / dead space is a no-op. Ctrl -// held at drop = copy (the deliberate secondary), else move. +// L7 in-grid REORDER drop: move the grabbed card to targetSlot within its bank (gap- +// preserving; onto a gap = place there, onto an occupant = insert-before-and-shift — the pure +// BankBook::reorderSample owns the semantics). One drop = one Ctrl-Z (persistBankOp opens the +// batched undo point + saves). A no-op reorder (already at the target, model returns false) +// opens no undo point. Selection reasons over slot order, so it is cleared after — the +// fingerprint pass rebuilds it against the new order. +void doReorderDrop(const std::string& id, const std::string& bankId, int targetSlot) { + if (!book() || id.empty() || bankId.empty() || targetSlot < 0) return; + if (!book()->reorderSample(id, bankId, targetSlot)) return; // rejected/no-op: no undo point + persistBankOp("ReaSampler: reorder sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// L7 Alt-REPLACE drop: the grabbed card `newId` takes the occupant `oldId`'s slot; `oldId` is +// removed from the bank's index (index-only, file untouched — pool guard enforced in the pure +// BankBook::replaceSample). Rejected (pool guard / absent) = a true NO-OP: no fallback insert, +// no undo point (per spec). One drop = one Ctrl-Z on success. +void doReplaceDrop(const std::string& newId, const std::string& oldId, + const std::string& bankId) { + if (!book() || newId.empty() || oldId.empty() || bankId.empty()) return; + if (!book()->replaceSample(newId, oldId, bankId)) return; // pool-guard reject: NO-OP + persistBankOp("ReaSampler: replace sample"); + g_panel.selection = Selection{}; + invalidatePanel(); +} + +// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: +// * Reorder / Replace -> in-grid, within the source bank (L7); one Ctrl-Z each. +// * Move / Copy -> the EXISTING cross-bank transfer (unchanged; Ctrl = copy). +// * None -> a drop over dead space / the source-bank gap = no-op. +// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove. void onLBtnUp(int x, int y) { if (g_panel.dragging) { updateDropTarget(x, y); - std::string destId; - if (g_panel.dropKind == DropKind::PoolRegion) destId = std::string(kPoolBankId); - else if (g_panel.dropKind == DropKind::Tab || - g_panel.dropKind == DropKind::BanksRegion) - destId = g_panel.dropBankId; + classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) + const CardGesture g = g_panel.cardGesture; - if (!destId.empty() && destId != g_panel.dragSourceBankId && - !g_panel.dragSampleIds.empty()) { - transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, - /*copy=*/ctrlDown()); + if (g == CardGesture::Reorder) { + doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, + g_panel.dragTargetSlot); + } else if (g == CardGesture::Replace) { + // Replace targets the OCCUPANT of the target slot with the single grabbed card. + const bool isBanks = g_panel.dragSourceRegion == Region::Banks; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const int w = cr.right - cr.left, h = cr.bottom - cr.top; + const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h); + const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion); + const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot); + // Replace only makes sense for a single grabbed card over a DIFFERENT occupant. + if (!occupant.empty() && occupant != g_panel.dragPrimaryId) + doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId); + } else if (g == CardGesture::Move || g == CardGesture::Copy) { + const std::string destId = dropTargetBankId(); + if (!destId.empty() && destId != g_panel.dragSourceBankId && + !g_panel.dragSampleIds.empty()) { + transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, + /*copy=*/g == CardGesture::Copy); + } } + // CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None). + SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop if (GetCapture() == g_panel.hwnd) ReleaseCapture(); } else if (g_panel.dragArmed) { // Press-release on a selected cell with no drag: treat as a plain click that @@ -2698,6 +3017,9 @@ void onLBtnUp(int x, int y) { g_panel.dragging = false; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); invalidatePanel(); } @@ -2762,10 +3084,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_CAPTURECHANGED: - // Capture lost before a drag began (e.g. pointer left window pre-threshold - // and button released outside) — disarm so the state doesn't stay stale. - if (g_panel.dragArmed && !g_panel.dragging) { + // Capture lost (pointer left window pre-threshold and released outside, or another + // window stole capture mid-drag) — cancel the whole drag as a NO-OP so no stale + // state lingers, mirroring onLBtnUp's reset (peer-path symmetry). Nothing is + // mutated on a cancel; the cursor is restored to the arrow. + if (g_panel.dragArmed || g_panel.dragging) { g_panel.dragArmed = false; + g_panel.dragging = false; + g_panel.dropKind = DropKind::None; + g_panel.dropBankId.clear(); + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); invalidatePanel(); } return 0; @@ -2787,6 +3118,9 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { stopAudition(); g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; + g_panel.cardGesture = CardGesture::None; + g_panel.dragTargetSlot = -1; + g_panel.dragPrimaryId.clear(); g_panel.hovered = Hover{}; g_panel.tooltipShown = false; g_panel.hwnd = nullptr; diff --git a/src/capture.cpp b/src/capture.cpp index 118c408..48bb3b6 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -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 diff --git a/src/capture_realtime.cpp b/src/capture_realtime.cpp index 91ce1eb..4a5e16f 100644 --- a/src/capture_realtime.cpp +++ b/src/capture_realtime.cpp @@ -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(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::time(nullptr)); result.status = CaptureStatus::Ok; diff --git a/src/card_drag.cpp b/src/card_drag.cpp new file mode 100644 index 0000000..2e52008 --- /dev/null +++ b/src/card_drag.cpp @@ -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 computeSlotRects(int maxSlot, int panelWidth, + const GridSpec& spec) { + std::vector 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(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& 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 diff --git a/src/card_drag.h b/src/card_drag.h new file mode 100644 index 0000000..2fa2128 --- /dev/null +++ b/src/card_drag.h @@ -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 + +#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 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& rects); + +} // namespace reasampler diff --git a/src/card_meta.cpp b/src/card_meta.cpp new file mode 100644 index 0000000..b2e44e9 --- /dev/null +++ b/src/card_meta.cpp @@ -0,0 +1,64 @@ +// card_meta — pure implementation. See card_meta.h. NO REAPER / SWELL / LICE / vendor. + +#include "card_meta.h" + +#include +#include + +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(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(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(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((len - static_cast(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 diff --git a/src/card_meta.h b/src/card_meta.h new file mode 100644 index 0000000..1c97fad --- /dev/null +++ b/src/card_meta.h @@ -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 + +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 (rounded to nearest ms). +// 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 diff --git a/src/main.cpp b/src/main.cpp index ce5f399..a8e5b8c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 diff --git a/src/persist.cpp b/src/persist.cpp index dbe914e..1986840 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -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 diff --git a/src/realtime_record.cpp b/src/realtime_record.cpp index ad3fc4c..9175005 100644 --- a/src/realtime_record.cpp +++ b/src/realtime_record.cpp @@ -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 diff --git a/src/realtime_record.h b/src/realtime_record.h index 043277b..82fec4c 100644 --- a/src/realtime_record.h +++ b/src/realtime_record.h @@ -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) }; diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index 8893187..d1c68eb 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -14,6 +14,7 @@ #include #include +#include using namespace reasampler; @@ -785,6 +786,279 @@ static void testUpdateSampleInPlace() { CHECK(!book.updateSampleInPlace("id-nope", sampleWith("nope"))); } +// =========================================================================== +// L7 — SlotMap (gap-preserving display positions) + BankBook ordering/reorder/replace +// =========================================================================== + +// --- SlotMap unit behaviour -------------------------------------------------- + +static void testSlotMapDenseAppend() { + SlotMap m; + m.append("a"); + m.append("b"); + m.append("c"); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("b") == 1); + CHECK(m.slotOf("c") == 2); + CHECK(m.maxSlot() == 2); + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.idAt(1) == "b"); + CHECK(m.slotOf("nope") == -1); +} + +static void testSlotMapRemoveLeavesGap() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); // c did NOT shift down + CHECK(m.idAt(1).empty()); // gap preserved + CHECK((m.orderedIds() == std::vector{"a", "c"})); + CHECK(!m.remove("b")); // already gone +} + +static void testSlotMapAppendAfterGapGoesToFrontier() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("a"); // slot 0 empty + m.append("d"); // append goes AFTER last occupied (2) -> 3 + CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap + CHECK(m.idAt(0).empty()); +} + +static void testSlotMapReorderIntoEmpty() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // slot 1 empty + CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties + CHECK(m.slotOf("c") == 1); + CHECK(m.idAt(2).empty()); + CHECK(m.slotOf("a") == 0); // untouched +} + +static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 + CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up + CHECK(m.slotOf("a") == 0); // before the target: unchanged + CHECK(m.slotOf("d") == 1); // took the target slot + CHECK(m.slotOf("b") == 2); // shifted +1 + CHECK(m.slotOf("c") == 3); // shifted +1 + CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); +} + +static void testSlotMapReorderPreservesInteriorGapAboveTarget() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.remove("b"); // gap at 1: a@0, c@2 + m.append("d"); // d@3 + CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 + CHECK(m.slotOf("d") == 0); + CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 + CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) + CHECK(m.idAt(2).empty()); // interior gap above the target survives +} + +static void testSlotMapReorderUnmappedIsNoOp() { + SlotMap m; + m.append("a"); + CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation + CHECK(m.slotOf("a") == 0); +} + +static void testSlotMapNegativeTargetClampsToZero() { + SlotMap m; + m.append("a"); m.append("b"); // 0,1 + CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a + CHECK(m.slotOf("b") == 0); + CHECK(m.slotOf("a") == 1); +} + +static void testSlotMapResetDenseSkipsDupesAndEmpties() { + SlotMap m; + m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped + CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); + CHECK(m.slotOf("a") == 0); + CHECK(m.slotOf("c") == 2); +} + +static void testSlotMapReconcileDropsStaleAppendsNew() { + SlotMap m; + m.append("a"); m.append("b"); m.append("c"); // 0,1,2 + m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) + CHECK(m.slotOf("a") == 0); // kept at its slot + CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) + CHECK(m.slotOf("b") == -1); // stale marker dropped + CHECK(m.slotOf("d") == 3); // appended after the frontier + CHECK(m.idAt(1).empty()); // b's slot stays empty +} + +static void testSlotMapEqualityAndFromEntries() { + SlotMap a; + a.append("x"); a.append("y"); + SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); + CHECK(a == b); + // Defensive repair: duplicate id (first wins), slot conflict (later dropped), + // empty id / negative slot dropped. + SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); + CHECK(c.slotOf("x") == 0); // first x wins + CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped + CHECK(c.slotOf("w") == 2); // valid + CHECK(c.slotOf("z") == -1); // negative slot dropped +} + +// --- BankBook L7: JSON round-trip WITH positions ----------------------------- + +static void testBankBookSlotsRoundTrip() { + BankBook book; + CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("p2")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("p3")) == AddResult::Added); + book.reconcileSlots(); // seed dense: p1@0, p2@1, p3@2 + CHECK(book.reorderSample("id-p3", kPoolBankId, 0)); // p3 -> 0, p1->1, p2->2 + CHECK(book.removeSample("id-p1", kPoolBankId) == RemoveResult::Removed); // gap at 1 + + const std::string json = book.serialize(); + auto back = BankBook::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == book); // positions (incl. the gap) survive + if (back) CHECK(back->serialize() == json); // idempotent + if (back) { + // p3 kept slot 0; p2 kept slot 2; slot 1 (where p1's shifted position was) is a gap. + CHECK(back->pool().slots.slotOf("id-p3") == 0); + CHECK(back->pool().slots.slotOf("id-p2") == 2); + CHECK(back->pool().slots.idAt(1).empty()); + } +} + +// --- BankBook L7: migration default (pre-L7 blob, no slots) ------------------- + +static void testMigrationDefaultsToInsertionOrderDense() { + // A pre-L7 legacy bank_index blob carries no slot data. On load -> reconcileSlots + // seeds dense insertion order (no gaps), so it is visually identical. + BankIndex legacy; + CHECK(legacy.add(sampleWith("o1")) == AddResult::Added); + CHECK(legacy.add(sampleWith("o2")) == AddResult::Added); + CHECK(legacy.add(sampleWith("o3")) == AddResult::Added); + auto back = BankBook::deserialize(legacy.serialize()); + CHECK(back.has_value()); + if (back) { + back->reconcileSlots(); // the persist load path calls this + CHECK((back->orderedSampleIds(kPoolBankId) == + std::vector{"id-o1", "id-o2", "id-o3"})); + CHECK(back->pool().slots.maxSlot() == 2); // dense, no gaps + } +} + +static void testOrderedSampleIdsReconcilesLazily() { + // Samples added straight to the index (capture path) without touching slots are + // reconciled on the first orderedSampleIds query (dense append in insertion order). + BankBook book; + CHECK(book.pool().index.add(sampleWith("c1")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("c2")) == AddResult::Added); + CHECK((book.orderedSampleIds(kPoolBankId) == + std::vector{"id-c1", "id-c2"})); + // Unknown bank -> empty. + CHECK(book.orderedSampleIds("no-such-bank").empty()); +} + +// --- BankBook L7: reorder mutator -------------------------------------------- + +static void testReorderSampleRejectsUnknown() { + BankBook book; + CHECK(book.pool().index.add(sampleWith("r1")) == AddResult::Added); + book.reconcileSlots(); + CHECK(!book.reorderSample("id-r1", "no-bank", 0)); // unknown bank + CHECK(!book.reorderSample("id-ghost", kPoolBankId, 0)); // not a member + CHECK(book.pool().slots.slotOf("id-r1") == 0); // unchanged +} + +// Same-slot reorder is a true no-op: returns false, no undo point triggered, +// JSON byte-identical before/after (the invariant that blocks spurious dirty-state). +static void testReorderSampleSameSlotIsNoOp() { + BankBook book; + CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("c")) == AddResult::Added); + book.reconcileSlots(); // a@0, b@1, c@2 + const std::string jsonBefore = book.serialize(); + + // Drop each card onto its own current slot — must return false every time. + CHECK(!book.reorderSample("id-a", kPoolBankId, 0)); + CHECK(!book.reorderSample("id-b", kPoolBankId, 1)); + CHECK(!book.reorderSample("id-c", kPoolBankId, 2)); + + // Slots and JSON are byte-identical — no mutation occurred. + CHECK(book.pool().slots.slotOf("id-a") == 0); + CHECK(book.pool().slots.slotOf("id-b") == 1); + CHECK(book.pool().slots.slotOf("id-c") == 2); + CHECK(book.serialize() == jsonBefore); +} + +// --- BankBook L7: Alt-replace mutator ---------------------------------------- + +static void testReplaceSampleTakesSlotAndRemovesOccupant() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("c")) == AddResult::Added); + book.reconcileSlots(); // a@0, b@1, c@2 + // Drag c (the newId) onto b (the occupant/oldId) with Alt -> c takes slot 1, b removed. + CHECK(book.replaceSample("id-c", "id-b", "drums")); + CHECK(book.bank("drums")->index.query("id-b") == nullptr); // occupant removed from index + CHECK(book.bank("drums")->index.query("id-c") != nullptr); // dragged sample survives + CHECK(book.bank("drums")->slots.slotOf("id-c") == 1); // took the vacated slot + CHECK(book.bank("drums")->slots.slotOf("id-a") == 0); // untouched + CHECK(book.bank("drums")->slots.idAt(2).empty()); // c's old slot emptied +} + +static void testReplaceSampleNonDestructiveFileStays() { + // Replace is INDEX-ONLY: the removed occupant's FILE is never touched. We assert the + // model does not mutate relativePath / does not report a disk op — the removed entry's + // hash can still be referenced elsewhere (the last-reference story is unchanged). + BankBook book; + CHECK(book.createBank("drums", "Drums")); + // Same-hash sample lives in BOTH the pool and drums (a copy). Replacing it out of drums + // leaves the pool's reference intact -> hashReferencedElsewhere still true for the pool. + Sample shared = sampleWith("shared", "shared-hash"); + CHECK(book.pool().index.add(shared) == AddResult::Added); + CHECK(book.bank("drums")->index.add(shared) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("dragged")) == AddResult::Added); + book.reconcileSlots(); // drums: shared@0, dragged@1 + CHECK(book.replaceSample("id-dragged", "id-shared", "drums")); + CHECK(book.bank("drums")->index.query("id-shared") == nullptr); // gone from drums + CHECK(book.pool().index.query("id-shared") != nullptr); // pool copy survives + CHECK(book.hashReferencedElsewhere("shared-hash", "drums")); // last-ref story intact +} + +static void testReplaceSampleRejectionsNoMutation() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added); + book.reconcileSlots(); + const BankBook snapshot = book; // capture full state to prove no-mutation + + CHECK(!book.replaceSample("id-a", "id-a", "drums")); // newId == oldId + CHECK(!book.replaceSample("id-a", "id-b", "no-bank")); // unknown bank + CHECK(!book.replaceSample("id-ghost", "id-b", "drums")); // newId not a member + CHECK(!book.replaceSample("id-a", "id-ghost", "drums")); // oldId not a member + CHECK(book == snapshot); // every rejection left the book byte-identical +} + +static void testReplaceSampleInPoolPassesGuard() { + // The pool guard: per-sample remove from the pool is permitted, so Alt-replace over a + // pool occupant succeeds whenever the occupant exists (no pool-only rejection path). + BankBook book; // pool only + CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added); + CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added); + book.reconcileSlots(); // a@0, b@1 + CHECK(book.replaceSample("id-b", "id-a", kPoolBankId)); // b replaces a in the pool + CHECK(book.pool().index.query("id-a") == nullptr); + CHECK(book.pool().slots.slotOf("id-b") == 0); // took a's slot +} + int main() { testPoolSeededAndDefaults(); testPoolPrivileges(); @@ -822,6 +1096,28 @@ int main() { testRemoveAllBanksLatentScope(); testUpdateSampleInPlace(); + // L7 — SlotMap + ordering/reorder/replace + slot round-trip/migration. + testSlotMapDenseAppend(); + testSlotMapRemoveLeavesGap(); + testSlotMapAppendAfterGapGoesToFrontier(); + testSlotMapReorderIntoEmpty(); + testSlotMapReorderOntoOccupiedInsertsAndShifts(); + testSlotMapReorderPreservesInteriorGapAboveTarget(); + testSlotMapReorderUnmappedIsNoOp(); + testSlotMapNegativeTargetClampsToZero(); + testSlotMapResetDenseSkipsDupesAndEmpties(); + testSlotMapReconcileDropsStaleAppendsNew(); + testSlotMapEqualityAndFromEntries(); + testBankBookSlotsRoundTrip(); + testMigrationDefaultsToInsertionOrderDense(); + testOrderedSampleIdsReconcilesLazily(); + testReorderSampleRejectsUnknown(); + testReorderSampleSameSlotIsNoOp(); + testReplaceSampleTakesSlotAndRemovesOccupant(); + testReplaceSampleNonDestructiveFileStays(); + testReplaceSampleRejectionsNoMutation(); + testReplaceSampleInPoolPassesGuard(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 9a77c56..53aaf22 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -33,6 +33,8 @@ static Sample fullSample(const std::string& seed) { s.lengthSeconds = 3.141592653589793; s.lengthBeats = 4.0; s.captureTempo = 128.5; + s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips) + s.captureTimeSigDenom = 8; s.key = "F#m"; s.levels = {-0.3, -12.7, -14.2}; s.clipped = true; @@ -77,6 +79,11 @@ static void testFullFieldRoundTrip() { if (back) { const Sample* full = back->query("id-a"); CHECK(full && full->key.has_value() && *full->key == "F#m"); + // L7 F1 meter stamp survived exactly. + CHECK(full && full->captureTimeSigNum == 6 && full->captureTimeSigDenom == 8); + // The minimal sample never stamped a meter -> 0/0 (the unstamped default). + const Sample* minMeter = back->query("min-b"); + CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0); CHECK(full && full->provenance.has_value()); CHECK(full && full->provenance->fxChainSnapshot == ""); diff --git a/tests/test_card_drag.cpp b/tests/test_card_drag.cpp new file mode 100644 index 0000000..f0fe377 --- /dev/null +++ b/tests/test_card_drag.cpp @@ -0,0 +1,201 @@ +// Standalone tests for reasampler::card_drag — no REAPER, no test framework. Asserts the +// L7 in-grid reorder drag decision logic + sparse-aware slot layout/hit-test. +// +// Covers: gesture precedence (no-drag/empty -> None; leave-client -> OsDragOut wins first; +// other-bank -> Move/Copy on Ctrl; same-bank grid empty vs occupied+no-mod -> Reorder; +// same-bank occupied+Alt -> Replace; Alt over EMPTY slot -> Reorder not Replace; dead space +// -> None); cursor-cue mapping (incl. Replace only for Replace); slot rects include empties +// (gap layout), dense layout matches a plain grid, slot hit-test returns slot index + miss. + +#include "../src/card_drag.h" + +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A 200x200 client at origin. A pointer at (10,10) is inside; (-5,10) / (250,10) are outside. +static const PanelClientRect kClient{0, 0, 200, 200}; +static const DragState kLiveDrag{/*dragging=*/true, /*hasArmedSamples=*/true}; + +static DragModifiers mods(DropRegion region, int slot, bool occupied, bool ctrl, bool alt) { + DragModifiers m; + m.region = region; + m.targetSlot = slot; + m.slotOccupied = occupied; + m.ctrl = ctrl; + m.alt = alt; + return m; +} + +// --- Gesture: guard cases ---------------------------------------------------- + +static void testNoDragIsNone() { + const DragState idle{/*dragging=*/false, /*hasArmedSamples=*/true}; + CHECK(decideCardGesture(10, 10, kClient, idle, + mods(DropRegion::SameBankGrid, 0, true, false, false)) == + CardGesture::None); +} + +static void testEmptyPayloadIsNone() { + const DragState noSamples{/*dragging=*/true, /*hasArmedSamples=*/false}; + CHECK(decideCardGesture(10, 10, kClient, noSamples, + mods(DropRegion::SameBankGrid, 0, true, false, false)) == + CardGesture::None); +} + +// --- Gesture: precedence 1 — leave client wins first ------------------------- + +static void testLeaveClientIsOsDragOut() { + // Pointer outside the client -> OsDragOut EVEN when the region verdict says same-bank + // and Alt is held (leave-client wins first — invariant #4 boundary). + CHECK(decideCardGesture(-5, 10, kClient, kLiveDrag, + mods(DropRegion::SameBankGrid, 0, true, /*ctrl=*/true, /*alt=*/true)) == + CardGesture::OsDragOut); + CHECK(decideCardGesture(250, 10, kClient, kLiveDrag, + mods(DropRegion::OtherBankOrTab, -1, false, false, false)) == + CardGesture::OsDragOut); +} + +// --- Gesture: precedence 2 — other bank/tab = move/copy ---------------------- + +static void testOtherBankIsMove() { + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::OtherBankOrTab, -1, false, /*ctrl=*/false, false)) == + CardGesture::Move); +} + +static void testOtherBankCtrlIsCopy() { + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::OtherBankOrTab, -1, false, /*ctrl=*/true, false)) == + CardGesture::Copy); +} + +// --- Gesture: precedence 3 — same-bank grid reorder / replace ---------------- + +static void testSameBankEmptySlotIsReorder() { + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::SameBankGrid, 3, /*occupied=*/false, false, false)) == + CardGesture::Reorder); +} + +static void testSameBankOccupiedNoModIsReorder() { + // Occupied + no modifier = insert-before-and-shift, which is still Reorder. + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::SameBankGrid, 2, /*occupied=*/true, false, false)) == + CardGesture::Reorder); +} + +static void testSameBankOccupiedAltIsReplace() { + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::SameBankGrid, 2, /*occupied=*/true, false, /*alt=*/true)) == + CardGesture::Replace); +} + +static void testAltOverEmptySlotIsReorderNotReplace() { + // Alt over an EMPTY slot must NOT be Replace (replace needs an occupant). + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::SameBankGrid, 5, /*occupied=*/false, false, /*alt=*/true)) == + CardGesture::Reorder); +} + +static void testDeadSpaceIsNone() { + CHECK(decideCardGesture(10, 10, kClient, kLiveDrag, + mods(DropRegion::DeadSpace, -1, false, false, true)) == + CardGesture::None); +} + +// --- Cursor cue mapping ------------------------------------------------------ + +static void testCursorCueMapping() { + CHECK(cursorForGesture(CardGesture::None) == CursorCue::Default); + CHECK(cursorForGesture(CardGesture::OsDragOut) == CursorCue::OsDragOut); + CHECK(cursorForGesture(CardGesture::Move) == CursorCue::Move); + CHECK(cursorForGesture(CardGesture::Copy) == CursorCue::Copy); + CHECK(cursorForGesture(CardGesture::Reorder) == CursorCue::Reorder); + CHECK(cursorForGesture(CardGesture::Replace) == CursorCue::Replace); +} + +static void testReplaceCueOnlyFromReplace() { + // The Replace cue is produced by NO gesture other than Replace (which itself requires + // Alt-over-occupied) — the "replace cursor only while Alt over occupied" guarantee. + CHECK(cursorForGesture(CardGesture::Reorder) != CursorCue::Replace); + CHECK(cursorForGesture(CardGesture::Move) != CursorCue::Replace); + CHECK(cursorForGesture(CardGesture::Copy) != CursorCue::Replace); + CHECK(cursorForGesture(CardGesture::None) != CursorCue::Replace); +} + +// --- Sparse slot layout + hit-test ------------------------------------------- + +// Spec: cell 100x40, gap 10. In a 340-wide panel: usable = 340-10 = 330; cell+gap = 110; +// cols = 330/110 = 3. +static const GridSpec kSpec{/*cellWidth=*/100, /*cellHeight=*/40, /*gap=*/10}; + +static void testSlotRectsIncludeEmpties() { + // maxSlot = 4 -> 5 rects, slots 0..4, three per row. + const std::vector r = computeSlotRects(4, 340, kSpec); + CHECK(r.size() == 5); + CHECK(r[0].slot == 0); + CHECK(r[4].slot == 4); + // Slot 0: x = gap = 10, y = gap = 10. + CHECK((r[0] == SlotCellRect{0, 10, 10, 100, 40})); + // Slot 3 wraps to row 1, col 0: y = gap + 1*(40+10) = 60. + CHECK((r[3] == SlotCellRect{3, 10, 60, 100, 40})); +} + +static void testSlotRectsEmptyWhenNoOccupied() { + CHECK(computeSlotRects(-1, 340, kSpec).empty()); +} + +static void testSlotRectsDenseMatchesGrid() { + // A dense slot layout (slots 0..N-1) lays out identically to bank_grid's item tiling. + const std::vector s = computeSlotRects(2, 340, kSpec); + const std::vector g = computeCellRects(3, 340, kSpec); + CHECK(s.size() == g.size()); + for (std::size_t i = 0; i < s.size(); ++i) { + CHECK(s[i].x == g[i].x && s[i].y == g[i].y); + CHECK(s[i].width == g[i].width && s[i].height == g[i].height); + } +} + +static void testHitTestSlotReturnsSlotIndex() { + const std::vector r = computeSlotRects(4, 340, kSpec); + // A point inside slot 3's rect (x=10,y=60) returns slot 3, not vector index 3 (they + // coincide here, but the point maps by geometry). + CHECK(hitTestSlot(15, 65, r) == 3); + // Inside slot 1 (x = gap + 1*(100+10) = 120). + CHECK(hitTestSlot(125, 15, r) == 1); +} + +static void testHitTestSlotMiss() { + const std::vector r = computeSlotRects(4, 340, kSpec); + CHECK(hitTestSlot(0, 0, r) == -1); // top-left margin (gap) is a miss + CHECK(hitTestSlot(115, 15, r) == -1); // inter-cell gap between slot 0 (ends x=110) and slot 1 (starts x=120) +} + +int main() { + testNoDragIsNone(); + testEmptyPayloadIsNone(); + testLeaveClientIsOsDragOut(); + testOtherBankIsMove(); + testOtherBankCtrlIsCopy(); + testSameBankEmptySlotIsReorder(); + testSameBankOccupiedNoModIsReorder(); + testSameBankOccupiedAltIsReplace(); + testAltOverEmptySlotIsReorderNotReplace(); + testDeadSpaceIsNone(); + testCursorCueMapping(); + testReplaceCueOnlyFromReplace(); + testSlotRectsIncludeEmpties(); + testSlotRectsEmptyWhenNoOccupied(); + testSlotRectsDenseMatchesGrid(); + testHitTestSlotReturnsSlotIndex(); + testHitTestSlotMiss(); + + if (g_fail == 0) std::printf("card_drag: all tests passed\n"); + else std::printf("card_drag: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_card_meta.cpp b/tests/test_card_meta.cpp new file mode 100644 index 0000000..37fcdbf --- /dev/null +++ b/tests/test_card_meta.cpp @@ -0,0 +1,132 @@ +// Standalone tests for reasampler::card_meta — no REAPER, no test framework. Asserts the +// L7 decorative overlay formatters: bars.beats.subdivisions (F1 tempo+meter stamp) and +// seconds.milliseconds. +// +// Covers: bar-1 origin (zero length), sub-bar, exact bar boundary rollover, multi-bar, +// long capture, non-4/4 meters (3/4 and 6/8), unstamped meter -> blank, unknown tempo -> +// blank (s.ms still derivable); s.ms zero / sub-second / multi-second / ms carry / negative. + +#include "../src/card_meta.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// Beat length in 4/4 at 120 BPM: (60/120)*(4/4) = 0.5 s/beat, so a 4/4 bar = 2.0 s. + +// --- bars.beats.subdivisions ------------------------------------------------- + +static void testZeroLengthIsBarOneOrigin() { + // Zero length -> the musical origin, "1.1.00". + CHECK(formatBarsBeats(MusicalLength{0.0, 120.0, 4, 4}) == "1.1.00"); +} + +static void testSubBeat() { + // 0.25 s at 120 BPM 4/4 = 0.5 beats -> bar 1, beat 1, .50. + CHECK(formatBarsBeats(MusicalLength{0.25, 120.0, 4, 4}) == "1.1.50"); +} + +static void testWholeBeatWithinBar() { + // 0.5 s = exactly 1 beat -> bar 1, beat 2, .00. + CHECK(formatBarsBeats(MusicalLength{0.5, 120.0, 4, 4}) == "1.2.00"); +} + +static void testExactBarBoundaryRollsOver() { + // 2.0 s = exactly 4 beats = 1 bar in 4/4 -> rolls to bar 2, beat 1 (NOT "1.5.00"). + CHECK(formatBarsBeats(MusicalLength{2.0, 120.0, 4, 4}) == "2.1.00"); +} + +static void testMultiBar() { + // 5.0 s at 120 4/4 = 10 beats = 2 bars + 2 beats -> "3.3.00". + CHECK(formatBarsBeats(MusicalLength{5.0, 120.0, 4, 4}) == "3.3.00"); +} + +static void testLongCaptureNoBarCap() { + // 256 s at 120 4/4 = 512 beats = 128 bars exactly -> bar 129, beat 1. + CHECK(formatBarsBeats(MusicalLength{256.0, 120.0, 4, 4}) == "129.1.00"); +} + +static void testThreeFourMeter() { + // 3/4 at 120 BPM: beat = (60/120)*(4/4)=0.5 s, bar = 3 beats = 1.5 s. 1.5 s -> bar 2,1. + CHECK(formatBarsBeats(MusicalLength{1.5, 120.0, 3, 4}) == "2.1.00"); + // 1.0 s = 2 beats -> bar 1, beat 3. + CHECK(formatBarsBeats(MusicalLength{1.0, 120.0, 3, 4}) == "1.3.00"); +} + +static void testSixEightMeter() { + // 6/8 at 120 BPM: an eighth-beat = (60/120)*(4/8) = 0.25 s; bar = 6 beats = 1.5 s. + // 1.5 s -> bar 2, beat 1. + CHECK(formatBarsBeats(MusicalLength{1.5, 120.0, 6, 8}) == "2.1.00"); + // 0.25 s = exactly 1 eighth-beat -> bar 1, beat 2. + CHECK(formatBarsBeats(MusicalLength{0.25, 120.0, 6, 8}) == "1.2.00"); +} + +static void testUnstampedMeterIsBlank() { + // 0/0 (pre-L7 sample) -> no musical read-out. + CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 0, 0}).empty()); + CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 4, 0}).empty()); // partial stamp also blank + CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 0, 4}).empty()); +} + +static void testUnknownTempoIsBlank() { + // Tempo 0 -> no musical read-out even with a meter (cannot derive beats). + CHECK(formatBarsBeats(MusicalLength{3.0, 0.0, 4, 4}).empty()); +} + +static void testNegativeLengthClampsToOrigin() { + // Defensive: a negative length reads as the origin, not garbage. + CHECK(formatBarsBeats(MusicalLength{-5.0, 120.0, 4, 4}) == "1.1.00"); +} + +// --- seconds.milliseconds ---------------------------------------------------- + +static void testSecondsMsZero() { + CHECK(formatSecondsMs(0.0) == "0.000"); +} + +static void testSecondsMsSubSecond() { + CHECK(formatSecondsMs(0.5) == "0.500"); +} + +static void testSecondsMsMultiSecond() { + CHECK(formatSecondsMs(1.5) == "1.500"); + CHECK(formatSecondsMs(62.037) == "62.037"); +} + +static void testSecondsMsNegativeClamps() { + CHECK(formatSecondsMs(-1.0) == "0.000"); +} + +static void testSecondsMsNearWholeSecondCarry() { + // 0.9999 s: floor to ms could hit 999 or 1000; the carry keeps it well-formed. + const std::string r = formatSecondsMs(0.9999); + CHECK(r == "0.999" || r == "1.000"); +} + +int main() { + testZeroLengthIsBarOneOrigin(); + testSubBeat(); + testWholeBeatWithinBar(); + testExactBarBoundaryRollsOver(); + testMultiBar(); + testLongCaptureNoBarCap(); + testThreeFourMeter(); + testSixEightMeter(); + testUnstampedMeterIsBlank(); + testUnknownTempoIsBlank(); + testNegativeLengthClampsToOrigin(); + testSecondsMsZero(); + testSecondsMsSubSecond(); + testSecondsMsMultiSecond(); + testSecondsMsNegativeClamps(); + testSecondsMsNearWholeSecondCarry(); + + if (g_fail == 0) std::printf("card_meta: all tests passed\n"); + else std::printf("card_meta: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}