Ψ-W1-T4: resolve drop targets per move, not once — every surface gets a defined outcome, a cue, and no silent no-op

This commit is contained in:
2026-08-01 19:43:37 -04:00
parent 8bf6841f7b
commit fe3ac79ab5
17 changed files with 842 additions and 398 deletions
+171 -134
View File
@@ -1,10 +1,10 @@
// 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.
// 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.
@@ -16,8 +16,9 @@
#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" // resolveFxDropTarget / performInstrumentDrop
#include "shell/actions/instrument_drop_win.h" // probeDropTarget / performInstrumentDrop
namespace reasampler::panel {
@@ -147,6 +148,88 @@ void applyDragCursor(CardGesture g) {
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; // the OS drag loop draws its own copy cursor
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 — that is the whole point (a transition
// reverses, and an unresolvable evaluation cannot poison a later one).
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, 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 (!(x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom)) {
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);
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
@@ -259,80 +342,26 @@ void onMouseMove(int x, int y) {
}
}
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);
// The class is re-resolved from scratch on every move — no first-move lock, nothing
// latched — so a transition in either direction always reverses, and drag speed cannot
// change where the gesture ends up.
const LiveDrop live = resolveLiveDrop(x, y);
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;
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();
if (live.cls == DropClass::OsHandoff) {
handOffToOs();
return;
}
applyDropCue(cueForDropClass(live.cls));
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) {
if (g_panel.dragOsHandoffBlocked) {
// Already known un-hand-off-able for this gesture (empty/unresolvable payload,
// or the OS wasn't ready) — skip the fs::exists work and the readiness probe on
// every move; keep the drag alive with no drop-target highlight.
g_panel.dropKind = DropKind::None;
g_panel.dropBankId.clear();
invalidatePanel();
return;
}
// 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".
// canInitiateDragOut extends the same coupling to the OS-readiness failure modes
// (OLE unavailable, HDROP build failure) that the pure decision cannot see.
const std::vector<std::string> paths = resolveDragPathsForOs();
const ui::OsHandoff handoff = ui::decideOsHandoff(paths);
if (!handoff.handOffToOs || !canInitiateDragOut(paths)) {
g_panel.dragOsHandoffBlocked = true;
g_panel.dropKind = DropKind::None;
g_panel.dropBankId.clear();
invalidatePanel();
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();
resetDragState();
invalidatePanel();
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.
@@ -369,6 +398,39 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId,
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
@@ -382,75 +444,50 @@ void resetDragState() {
g_panel.cardGesture = CardGesture::None;
g_panel.dragTargetSlot = -1;
g_panel.dragPrimaryId.clear();
g_panel.instrumentDropTrack = nullptr;
g_panel.dragOsHandoffBlocked = false;
}
// 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.
// Commits (or abandons) a drag on button-up, over the class resolved AT THE RELEASE POINT.
// The switch is deliberately exhaustive with no default: every DropClass either performs its
// outcome or is an explicit, already-cued refusal, so a new class cannot be added without
// answering "what does releasing here do?".
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. Gated on
// !inside exactly like onMouseMove's live resolve — GetThingFromPoint can return a
// track+FX hit at a release point that is still inside the panel's own client rect, and
// an in-grid release must always go through the reorder/replace/move/copy path below,
// never be reinterpreted as an FX add. Strictly additive otherwise — 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;
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
if (!inside && 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 (!inside && 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;
// Resolve at the release point, not from anything the moves remembered: WM_MOUSEMOVE is
// coalesced, so the last processed move can sit well away from where the button actually
// came up, and the release point is the user's stated target.
const LiveDrop live = resolveLiveDrop(x, y);
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);
}
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. An OsHandoff reaching release
// means the payload never resolved — a live hand-off consumes the drag inside
// DoDragDrop's modal loop and never returns here.
break;
}
// 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) {
+9 -10
View File
@@ -71,9 +71,11 @@ using ui::CardGesture;
using ui::CellRect;
using ui::ClusterSpec;
using ui::CursorCue;
using ui::DragGesture;
using ui::DragModifiers;
using ui::DragState;
using ui::DropClass;
using ui::DropContext;
using ui::DropCue;
using ui::DropRegion;
using ui::FooterBarLayout;
using ui::FooterBarSpec;
@@ -118,9 +120,10 @@ using ui::computeSlotRectsForDrop;
using ui::computeTabRects;
using ui::computeTabStripLayout;
using ui::computeTooltip;
using ui::cueForDropClass;
using ui::cursorForGesture;
using ui::decideCardGesture;
using ui::decideGesture;
using ui::decideDropClass;
using ui::formatBarsBeats;
using ui::formatSecondsMs;
using ui::hitTestActionBar;
@@ -317,14 +320,10 @@ struct PanelState {
CardGesture cardGesture = CardGesture::None;
int dragTargetSlot = -1;
// While a single-capture drag is over REAPER's own UI, heading for a track's TCP FX
// button: on release this adds a ReaSampler 9000 preloaded with the capture. Null
// when the pointer is not over an FX button.
MediaTrack* instrumentDropTrack = nullptr;
// Latched once an OsDrag hand-off resolves "cannot hand off" for this gesture — skips
// re-running resolveDragPathsForOs (fs::exists per sample) each move. Cleared by resetDragState.
bool dragOsHandoffBlocked = false;
// NO out-of-client drop-target state is kept here on purpose. The drag-out class and its
// track are re-resolved from the live pointer on every move and again at the release point,
// which is what makes a class transition reversible and one unresolvable evaluation
// harmless to the next. Do not reintroduce a remembered target or a "blocked" latch.
// Authoritative tail setting lives in ReaSamplerSession, not here; panel reads it for
// drawing and mutates via footer click / scroll-wheel. bankPanelTailSetting is the