From 439bb44e0d739f18046a42421a09e908715a9f9a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 02:02:57 -0400 Subject: [PATCH] L7 Wave 2: wire capture-ordering into bank_panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bank_panel.cpp | 407 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 352 insertions(+), 55 deletions(-) diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index e656c02..7db0a9f 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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 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 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 --------------------------------------------------- @@ -1277,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; @@ -1286,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, &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& 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()); @@ -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(idx->size()) : 0; @@ -2002,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; } @@ -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 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) { @@ -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(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;