// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel: // WM_MOUSEMOVE (hover + tooltip timing + the live drag), drop-target/gesture // classification, cursor cues, button-up drop dispatch, and right-click menu routing. // Its PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test), with // core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only the // live rects, modifier state, and side effects. Per-mouse-move work stays plain // free-function calls — no interface, no virtual dispatch. // // Compiled into the reaper_reasampler MODULE. No REAPER API functions are called // directly here; REAPER SDK types arrive via panel_state.h. #include // std::abs (drag threshold) #include #include #include "shell/panel/panel_state.h" #include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper #include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam #include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop namespace reasampler::panel { constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag namespace { // Resolves the drop target under client (x, y) during a drag, updating dropKind / // dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; // anywhere else -> none. void updateDropTarget(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { g_panel.dropKind = DropKind::Tab; g_panel.dropBankId = tabs[static_cast(hit.index)]->id; return; } // Tab takes precedence; otherwise the whole grid is a drop zone for the shown bank. if (!g_panel.shownBankId.empty() && book() && book()->bank(g_panel.shownBankId)) { if (x >= br.left && x < br.right && y >= br.top && y < br.bottom) { g_panel.dropKind = DropKind::BanksRegion; g_panel.dropBankId = g_panel.shownBankId; return; } } } if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT grid = regionGridRect(pr, false); if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { g_panel.dropKind = DropKind::PoolRegion; return; } } } // The destination bank id under the current drop target (pool id for PoolRegion; the // tab/shown-bank id for Tab/BanksRegion; "" for no target). 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 {}; } // Classifies the live in-grid drag into a pure CardGesture and (for a same-bank drop) // the target slot. Call AFTER updateDropTarget so dropKind/dropBankId are current. // card_drag::decideCardGesture owns the precedence (other-bank -> move/copy; same-bank // grid -> reorder/replace); this only supplies the region verdict, target slot + // occupancy, and modifier state (the OS-drag-out boundary is handled earlier, in // onMouseMove, so here the pointer is always inside). 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 + occupancy in the // SOURCE bank's display. computeSlotRectsForDrop adds one trailing row past // maxSlot so a drop beyond the last card resolves to a valid slot, not a -1 miss. 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 RECT grid = regionGridRect(region, isBanks); const int gridW = grid.right - grid.left; const std::vector dropRects = computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1, gridW, kGrid); // Translate the drop rects to client space (matching regionDisplay's translation). std::vector dropRectsClient = dropRects; for (SlotCellRect& r : dropRectsClient) { r.x += grid.left; r.y += grid.top; } const int slot = hitTestSlot(x, y, dropRectsClient); 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 cursor cue to a SWELL stock cursor (vendor/WDL/WDL/swell/swell-types.h, // mirroring Win32 OCR_*) and sets it; the cue decision is pure (card_drag::cursorForGesture). // Copy has no stock cross-platform cursor, so IDC_UPARROW is the closest distinct stock cue // (a bespoke resource was deliberately not added). OsDragOut leaves the cursor alone — the // OS drag loop owns it once handed off, and this branch is never actually seen. 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 (the // grid cells carry their own selection/focus chrome, not a kit hover surface). Hover resolveHover(int x, int y) { if (!g_panel.hwnd) return Hover{}; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; // TOP toolbar, then footer, then BOTTOM toolbar, matching handleClick's precedence. { const MenuButtonRect mb = topMenuButtonRect(w); if (hitTestMenuButton(x, y, mb)) return Hover{HoverKind::MoreButton, -1}; const int hit = toolbarHit(x, y, topToolbarActionRect(w), topBarRows()); if (hit >= 0) return Hover{HoverKind::TopBarButton, hit}; } { const int seg = footerToggleSegmentHit(x, y, w, h); if (seg >= 0) return Hover{HoverKind::ModeSegment, seg}; const FooterBarLayout fb = footerBarLayoutFor(w, h); if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1}; const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1}; } { const int hit = toolbarHit(x, y, bottomToolbarRect(w, h), bottomBarRows()); if (hit >= 0) return Hover{HoverKind::BottomBarButton, hit}; } if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT ftb = fullHtBtnRect(pr); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) return Hover{HoverKind::FullHtPool, -1}; } if (banksShown()) { const RECT br = banksRegionRect(w, h); const RECT ftb = fullHtBtnRect(br); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) return Hover{HoverKind::FullHtBanks, -1}; const RECT cb = createBtnRect(br); if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) return Hover{HoverKind::CreateBank, -1}; const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) return Hover{HoverKind::Tab, hit.index}; } return Hover{}; } // Updates the live hover element and repaints ONLY on a change (sub-frame feedback, no // per-move jank). A hover CHANGE also resets the tooltip timer (hoverSinceTick) and hides // any shown tooltip, so the tooltip only appears after the pointer rests kTooltipDelayMs // on ONE element (applied by the poll tick in maybeShowTooltip); a move within the SAME // element leaves the timer running. void updateHover(int x, int y) { const Hover next = resolveHover(x, y); if (next != g_panel.hovered) { g_panel.hovered = next; g_panel.hoverSinceTick = GetTickCount(); if (g_panel.tooltipShown) { g_panel.tooltipShown = false; } invalidatePanel(); } } } // namespace // Applies the tooltip hover-delay: if a tooltip-bearing element has been hovered past // kTooltipDelayMs and the tooltip is not yet shown, latch it and repaint once. Driven from // the OnTimer poll (bankPanelRefresh) so the tooltip appears after a rest with no dedicated // timer; updateHover resets the timer on every move, so a moving pointer never trips it. void maybeShowTooltip() { if (g_panel.tooltipShown) return; const HoverKind k = g_panel.hovered.kind; if (k != HoverKind::TopBarButton && k != HoverKind::BottomBarButton) return; const unsigned int now = GetTickCount(); if (now - g_panel.hoverSinceTick >= kTooltipDelayMs) { g_panel.tooltipShown = true; invalidatePanel(); } } void onMouseMove(int x, int y) { // Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns // the visual feedback then — a drop-target highlight, not a hover). if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); if (g_panel.dragArmed && !g_panel.dragging) { if (std::abs(x - g_panel.dragStartX) > kDragThreshold || std::abs(y - g_panel.dragStartY) > kDragThreshold) { // Threshold crossed — begin the drag. Snapshot the payload NOW. 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 — the in-grid // reorder/replace subject (see onLBtnUp), 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 was already called at drag-arm time (handleClick); no re-capture needed. } } if (g_panel.dragging) { // Inside the client rect it stays the internal bank-to-bank drag. Once it LEAVES, // drag_out::decideGesture splits three ways: single-capture over REAPER's OWN UI -> // InstrumentDrop; multi-capture or fully outside REAPER -> OsDrag; inside -> Internal. RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top}; const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom); DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()}; st.singleCapture = (g_panel.dragSampleIds.size() == 1); // Only resolved OUTSIDE the client rect and for a single-capture payload, so the SDK // hit-test costs nothing on the common internal-drag path. FxDropTarget fx; if (!inside && st.singleCapture) { POINT sp{x, y}; ClientToScreen(g_panel.hwnd, &sp); fx = resolveFxDropTarget(sp.x, sp.y); st.overReaperUi = fx.overReaperUi; } const DragGesture gesture = decideGesture(x, y, client, st); if (gesture == DragGesture::InstrumentDrop) { // Track the FX hotspot for the release. Unlike OsDrag this does NOT hand off to // a modal OS loop, so the internal-drag capture stays alive; clear any bank // drop-target highlight so the panel doesn't paint that cue too. g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); invalidatePanel(); return; } // Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target. g_panel.instrumentDropTrack = nullptr; if (gesture == DragGesture::OsDrag) { // Resolve the payload to existing on-disk paths BEFORE tearing down internal // drag state (the resolver reads dragSourceBankId / dragSampleIds), then let the // pure rule couple the two side effects: an unresolvable payload must leave the // internal drag intact rather than wind it down for a hand-off that never runs — // a half-torn-down drag reads to the user as "the drag did nothing, try again". const std::vector paths = resolveDragPathsForOs(); const ui::OsHandoff handoff = ui::decideOsHandoff(paths); if (!handoff.releaseInternalDrag) return; // DoDragDrop runs its own modal loop and takes over mouse capture, so the internal // drag must be fully wound down first. if (GetCapture() == g_panel.hwnd) ReleaseCapture(); 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(); invalidatePanel(); if (handoff.startOsDrag) initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows return; } // Inside the client: classify the in-grid gesture (reorder/replace vs 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(); } } // 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. A no-op reorder // opens no undo point. Selection reasons over slot order, so it is cleared after. namespace { 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(*g_panel.session, "ReaSampler: reorder sample"); g_panel.selection = Selection{}; invalidatePanel(); } // 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 = a true NO-OP: no fallback insert, no undo point. 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(*g_panel.session, "ReaSampler: replace sample"); g_panel.selection = Selection{}; invalidatePanel(); } } // namespace // Clears all drag-state fields to their resting values. Called from every exit path // (button-up, WM_CAPTURECHANGED, WM_DESTROY, closePanel) so the set of cleared fields // stays consistent across all four sites. void resetDragState() { 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(); g_panel.instrumentDropTrack = nullptr; } // Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides: // * Reorder / Replace -> in-grid, within the source bank; one Ctrl-Z each. // * Move / Copy -> the cross-bank transfer (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) { // Drop-and-load: a release over a valid FX hotspot instantiates a ReaSampler 9000 on // that track preloaded with the dragged capture — NOT a bank move, NOT an OS drag, // NEVER a timeline insert. Takes priority over the in-grid / cross-bank drop. // Single-capture only, so dragSampleIds.front() is the capture. // // Re-resolve at the RELEASE point rather than trusting only the hover-tracked target: // WM_MOUSEMOVE is coalesced, so a fast drag onto a dense surface (an FX chain row, a // container) can release over a hotspot no processed move ever reported. Strictly // additive — a release-point miss falls back to the tracked target, so the // hover-then-release-on-the-FX-button path is untouched. const bool singleCapture = g_panel.dragSampleIds.size() == 1; MediaTrack* dropTrack = g_panel.instrumentDropTrack; if (singleCapture) { POINT sp{x, y}; ClientToScreen(g_panel.hwnd, &sp); const FxDropTarget fx = resolveFxDropTarget(sp.x, sp.y); if (fx.valid()) dropTrack = fx.track; } if (dropTrack && singleCapture) { const std::string sampleId = g_panel.dragSampleIds.front(); performInstrumentDrop(dropTrack, buildInstrumentDropPreset(sampleId)); // Read-only over the bank + arrange: the only mutations are the new FX instance + // its state (both undoable in performInstrumentDrop). } else { updateDropTarget(x, y); classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed) const CardGesture g = g_panel.cardGesture; 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 // collapses the multi-selection to the pressed cell (standard behavior). // Release capture acquired at arm time (handleClick) — drag never started. if (GetCapture() == g_panel.hwnd) ReleaseCapture(); const BankModel* idx = indexForRegion(g_panel.focusedRegion); const int count = idx ? static_cast(idx->size()) : 0; const int focus = g_panel.selection.focus; if (focus >= 0) g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); } resetDragState(); invalidatePanel(); } // A right-click: on a named tab -> the tab management menu; on a grid cell of the // focused region with a selection -> the move/copy menu. void handleRightClick(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); return; } } // Grid selection menu (move/copy). Only when the right-click lands in the focused // region's grid and there is a selection. Region reg = Region::Pool; if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && !g_panel.selection.empty()) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showSelectionMenu(pt.x, pt.y); } } } // namespace reasampler::panel