559 lines
27 KiB
C++
559 lines
27 KiB
C++
// 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 mirrors are core/ui/card_drag (in-grid precedence + slot hit-test) and
|
|
// core/ui/drag_out (the out-of-client gesture law) — 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 <cstdlib> // std::abs (drag threshold)
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "shell/panel/panel_state.h"
|
|
|
|
#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper
|
|
#include "shell/actions/arrange_drop_win.h" // arrangeTimeAtScreenX / performArrangeDrop
|
|
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam
|
|
#include "shell/actions/instrument_drop_win.h" // probeDropTarget / 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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip,
|
|
static_cast<int>(tabs.size()), kTabSpec,
|
|
g_panel.tabScroll);
|
|
if (hit.kind == TabHitKind::Tab) {
|
|
g_panel.dropKind = DropKind::Tab;
|
|
g_panel.dropBankId = tabs[static_cast<std::size_t>(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<SlotCellRect> dropRects =
|
|
computeSlotRectsForDrop(disp.bank ? disp.bank->slots.maxSlot() : -1,
|
|
gridW, kGrid);
|
|
// Translate the drop rects to client space (matching regionDisplay's translation).
|
|
std::vector<SlotCellRect> 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));
|
|
}
|
|
|
|
// The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The
|
|
// cue is set on EVERY move, so "will this work" is visible before the button comes up, and a
|
|
// refusal is a cursor the user can see rather than a release that does nothing. Cursor-only by
|
|
// design — the docked panel is usually not under the pointer during an out-of-client drag, so
|
|
// panel status text would be invisible exactly when it is needed.
|
|
void applyDropCue(DropCue cue) {
|
|
const char* idc = nullptr;
|
|
switch (cue) {
|
|
case DropCue::Instrument: idc = IDC_HAND; break;
|
|
case DropCue::ArrangeInsert: idc = IDC_IBEAM; break; // an insertion point on a timeline
|
|
case DropCue::Refuse: idc = IDC_NO; break;
|
|
case DropCue::OsOwned: return; // reached once per move resolving to OsHandoff,
|
|
// right before handOffToOs is attempted; the OS
|
|
// drag loop (once it actually starts) draws its
|
|
// own copy cursor, so leave the cursor alone here
|
|
case DropCue::Internal: return; // applyDragCursor owns the in-client cue
|
|
case DropCue::None: return;
|
|
}
|
|
SetCursor(LoadCursor(nullptr, idc));
|
|
}
|
|
|
|
// One independent evaluation of the drag's target: the resolved class plus the live facts its
|
|
// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is
|
|
// per-move and stateless").
|
|
struct LiveDrop {
|
|
DropClass cls = DropClass::None;
|
|
MediaTrack* track = nullptr;
|
|
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
|
|
};
|
|
|
|
LiveDrop resolveLiveDrop(int x, int y) {
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
|
|
|
DropContext ctx;
|
|
ctx.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
|
ctx.singlePayload = g_panel.dragSampleIds.size() == 1;
|
|
|
|
LiveDrop out;
|
|
// The SDK hit-test is evaluated ONLY outside the client rect (needsSurfaceProbe — exported
|
|
// by the pure law so this gate can't drift from decideDropClass's own inside-client check),
|
|
// so the common internal-drag path costs nothing. Unlike before, it runs for multi payloads
|
|
// too — still per-mouse-move cold, and it is what gives a multi drag a defined outcome on
|
|
// every surface.
|
|
if (needsSurfaceProbe(x, y, client)) {
|
|
POINT sp{x, y};
|
|
ClientToScreen(g_panel.hwnd, &sp);
|
|
const DropProbe probe = probeDropTarget(sp.x, sp.y);
|
|
ctx.surface = probe.surface;
|
|
ctx.haveTrack = probe.track != nullptr;
|
|
out.track = probe.track;
|
|
out.screenX = sp.x;
|
|
}
|
|
out.cls = decideDropClass(x, y, client, ctx);
|
|
|
|
// ArrangeInsert has no defined outcome once every armed sample is stale/missing — the same
|
|
// gate handOffToOs applies via decideOsHandoff before an OS hand-off, so acceptance
|
|
// criterion 7 (no silent no-op release) holds on this cell too, at both a cueing move and
|
|
// the release itself (both call this function). Cheap: a few fs::exists checks, only
|
|
// reached outside the client — already a cold path.
|
|
if (out.cls == DropClass::ArrangeInsert && resolveDragPathsForOs().empty()) {
|
|
out.cls = DropClass::Refuse;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal
|
|
// loop, so the internal drag must be fully wound down first, and only once the payload is known
|
|
// to be hand-off-able. This runs on every qualifying move — do not memoize a failed attempt.
|
|
//
|
|
// Residual (accepted): once the pointer has left REAPER, dragging back INTO a REAPER window
|
|
// mid-modal-loop delivers a CF_HDROP to REAPER's own file-import drop target rather than to our
|
|
// gesture law. NOT confirmed by experiment — inferred from REAPER's handling of external file
|
|
// drops, and the inferred outcome (an item at the drop point) coincides with what our own
|
|
// arrange path would have done.
|
|
void handOffToOs() {
|
|
// Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let
|
|
// the pure rule couple the two side effects: an unresolvable payload leaves the internal
|
|
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
|
|
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
|
|
// coupling to the OS-readiness failures the pure decision cannot see.
|
|
const std::vector<std::string> paths = resolveDragPathsForOs();
|
|
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
|
|
applyDropCue(DropCue::Refuse);
|
|
invalidatePanel();
|
|
return;
|
|
}
|
|
|
|
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
|
resetDragState();
|
|
invalidatePanel();
|
|
|
|
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
|
|
}
|
|
|
|
// 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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip, static_cast<int>(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<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 was already called at drag-arm time (handleClick); no re-capture needed.
|
|
}
|
|
}
|
|
if (g_panel.dragging) {
|
|
// Re-resolved from scratch every move — core/ui/CLAUDE.md, "the drag-out law is
|
|
// per-move and stateless."
|
|
const LiveDrop live = resolveLiveDrop(x, y);
|
|
|
|
if (live.cls != DropClass::Internal) {
|
|
// Anything but the in-grid drag (including an empty payload, which resolves to
|
|
// None): clear the bank drop-target highlight so the panel does not paint a cue for
|
|
// a drop that is not going there, then show whatever cue this class carries.
|
|
g_panel.dropKind = DropKind::None;
|
|
g_panel.dropBankId.clear();
|
|
applyDropCue(cueForDropClass(live.cls)); // OsHandoff -> OsOwned, a documented no-op cue
|
|
if (live.cls == DropClass::OsHandoff) {
|
|
handOffToOs();
|
|
return;
|
|
}
|
|
invalidatePanel();
|
|
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();
|
|
}
|
|
|
|
// The in-client release: re-resolve the in-grid gesture at the drop point (modifiers may have
|
|
// changed since the last move) and commit it. Bank-to-bank semantics are unchanged.
|
|
void commitInternalDrop(int x, int y) {
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
|
|
updateDropTarget(x, y);
|
|
classifyCardDrag(x, y);
|
|
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;
|
|
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 -> a release over dead space or the source-bank gap: no bank change.
|
|
}
|
|
|
|
} // 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();
|
|
}
|
|
|
|
// Commits (or abandons) a drag on button-up, over the class resolved AT THE RELEASE POINT. Every
|
|
// DropClass either performs its outcome or is an explicit, already-cued refusal (core/ui/
|
|
// CLAUDE.md: "no DropClass means nothing happens"). The switch below has no default so each case
|
|
// is spelled out by hand — but this build sets no warning flags (root CLAUDE.md), so a missing
|
|
// case is NOT a compile error here; exhaustiveness is a review discipline, not a compiler
|
|
// guarantee.
|
|
void onLBtnUp(int x, int y) {
|
|
if (g_panel.dragging) {
|
|
// Resolved at the release point, not from what the (coalesced) moves last recorded —
|
|
// core/ui/CLAUDE.md, "the drag-out law is per-move and stateless."
|
|
const LiveDrop live = resolveLiveDrop(x, y);
|
|
|
|
switch (live.cls) {
|
|
case DropClass::InstrumentDrop: {
|
|
// Adds a ReaSampler 9000 on that track preloaded with the capture — NOT a bank
|
|
// move, NOT an OS drag, NEVER a timeline insert. singlePayload is what armed
|
|
// this class, so front() IS the capture.
|
|
const std::string sampleId = g_panel.dragSampleIds.front();
|
|
performInstrumentDrop(live.track, buildInstrumentDropPreset(sampleId));
|
|
break;
|
|
}
|
|
|
|
case DropClass::ArrangeInsert:
|
|
// The one branch here that places timeline items, and legitimately so — see
|
|
// arrange_drop_win.h for why a deliberate drop is not a capture auto-insert.
|
|
performArrangeDrop(live.track, arrangeTimeAtScreenX(live.screenX),
|
|
resolveDragPathsForOs());
|
|
break;
|
|
|
|
case DropClass::Internal:
|
|
commitInternalDrop(x, y);
|
|
break;
|
|
|
|
case DropClass::Refuse:
|
|
case DropClass::OsHandoff:
|
|
case DropClass::None:
|
|
// Nothing to perform, and nothing silent about it: the refuse cursor has been
|
|
// showing since the move that resolved this class. OsHandoff reaching release
|
|
// usually means a live hand-off consumed the drag inside DoDragDrop's modal loop
|
|
// and this call never ran — but WM_MOUSEMOVE coalescing can still deliver a
|
|
// WM_LBUTTONUP with no intervening processed move (or right after a
|
|
// canInitiateDragOut refusal), so this case CAN be reached with a stale
|
|
// OsHandoff/Refuse class; the no-op here is correct either way.
|
|
break;
|
|
}
|
|
|
|
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<int>(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<const Bank*> tabs = namedBanks();
|
|
const TabHit hit = hitTestTabStrip(x, y, strip,
|
|
static_cast<int>(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<std::size_t>(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
|