L7 Wave 2: wire capture-ordering into bank_panel

Render the grid in sparse SlotMap order with empty-slot cells; remap every
cell↔sample consumer (selection, nav, audition, drag) to occupied-ordinal
space. Drop dispatches reorder/replace/move/copy via card_drag; per-slot
highlight + stock cursor cues. One drop = one Ctrl-Z.
This commit is contained in:
2026-07-27 02:02:57 -04:00
parent fdb9e38ad6
commit 439bb44e0d
+352 -55
View File
@@ -54,6 +54,7 @@
#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)
@@ -304,9 +305,20 @@ struct PanelState {
Region dragSourceRegion = Region::Pool;
std::string dragSourceBankId; // the bank the dragged samples come from
std::vector<std::string> 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,
@@ -1256,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<CellRect> 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<std::string> orderedIds; // occupied ids in slot order (selection space)
std::vector<SlotCellRect> 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<int>(orderedIds.size()) || !bank) return -1;
return bank->slots.slotOf(orderedIds[static_cast<std::size_t>(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<int>(i);
return -1;
}
int occupiedCount() const { return static_cast<int>(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<CellRect> rects =
computeCellRects(static_cast<int>(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 ---------------------------------------------------
@@ -1277,7 +1353,7 @@ std::vector<CellRect> 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;
@@ -1286,20 +1362,63 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
return;
}
const std::vector<Sample>& samples = index->all();
const std::vector<CellRect> 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<int>(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, &samples[i]);
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;
}
}
@@ -1450,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.
@@ -1485,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
@@ -1748,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<Sample>& samples = index->all();
if (idx < 0 || idx >= static_cast<int>(samples.size())) return;
const RegionDisplay disp = focusedDisplay();
if (idx < 0 || idx >= disp.occupiedCount()) return;
const Sample* s = index->query(disp.orderedIds[static_cast<std::size_t>(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());
@@ -1786,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<int>(idx->size()) : 0;
@@ -2002,13 +2142,13 @@ void removeSamples(const std::vector<std::string>& 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<std::string> focusedSelectionIds() {
// L7: selection ordinals index the DISPLAY (slot) order, not BankIndex insertion order.
// orderedIds[i] is the id at selection ordinal i.
std::vector<std::string> ids;
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
if (!idx) return ids;
const std::vector<Sample>& samples = idx->all();
const int count = static_cast<int>(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<std::size_t>(i)].id);
if (i >= 0 && i < count) ids.push_back(disp.orderedIds[static_cast<std::size_t>(i)]);
return ids;
}
@@ -2343,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<CellRect> rects = regionCellRects(region, isBanks, index);
const int hit = hitTestCell(x, y, rects);
const int count = index ? static_cast<int>(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) {
@@ -2557,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
@@ -2658,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<std::size_t>(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);
@@ -2688,6 +2920,8 @@ 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;
invalidatePanel();
// Empty path list -> nothing draggable (all stale/missing); do not start a drag.
@@ -2695,28 +2929,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
@@ -2731,6 +3016,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();
}
@@ -2795,10 +3083,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;