Fix drag-handoff bugs: gate FX re-resolve on outside-panel, cache unresolvable OS-drag verdict, block double FX-add retry

This commit is contained in:
2026-07-30 00:09:00 -04:00
parent 0800760833
commit 875d5b4632
9 changed files with 110 additions and 75 deletions
+21 -4
View File
@@ -64,10 +64,11 @@ HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
// COM reference counts MUST be interlocked. A CF_HDROP target is free to marshal the data
// object into another apartment and finish the copy on a background thread AFTER DoDragDrop
// has returned (Explorer's async file copy does exactly this). A plain ++/-- there races the
// source thread's post-DoDragDrop Release: one lost increment destroys the object — and with
// it the source HGLOBAL — before the target reads it, and the drop lands with no file. That
// race is intermittent and a retry usually wins it; do not "simplify" these back.
// has returned; Explorer's async file copy is suspected to do exactly this, though that
// specific behavior is not confirmed by experiment. A plain ++/-- there races the source
// thread's post-DoDragDrop Release: one lost increment destroys the object — and with it the
// source HGLOBAL — before the target reads it, and the drop lands with no file. That race is
// intermittent and a retry usually wins it; do not "simplify" these back.
inline ULONG comAddRef(volatile LONG& refs) {
return static_cast<ULONG>(InterlockedIncrement(&refs));
}
@@ -234,6 +235,15 @@ bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& abso
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
}
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty()) return false;
if (!ensureOleForThisThread()) return false;
HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false;
GlobalFree(hdrop); // probe only — initiateDragOut builds its own on the real attempt
return true;
}
} // namespace reasampler
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
@@ -260,6 +270,13 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return)
}
// SWELL exposes no readiness probe ahead of SWELL_InitiateDragDropOfFileList (which itself
// reports no accept/cancel outcome) — the non-empty check is the only thing knowable in
// advance on this platform.
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
return !absolutePaths.empty();
}
} // namespace reasampler
#endif
+9
View File
@@ -26,4 +26,13 @@ namespace reasampler {
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
// Cheap, side-effect-free readiness probe: true iff initiateDragOut would actually be able to
// START a drag for `absolutePaths` right now. Call this BEFORE tearing down internal drag
// state (release capture, clear drag fields) — an OS that isn't ready (OLE unavailable, or the
// path list can't build a CF_HDROP) must not consume the gesture the same way an empty payload
// would. On Windows this re-runs the same OLE-init + HDROP-build checks initiateDragOut does,
// freeing the probe HGLOBAL immediately; SWELL exposes no such probe, so macOS/Linux reduces to
// the non-empty check alone.
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
} // namespace reasampler
+13 -6
View File
@@ -104,16 +104,23 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector<std::uint8_t>&
const std::string fxName = "VST3:" + vstPluginName();
// An EXPLICIT top-level insertion position (instantiate <= -1000 IS the position, -1000
// = first in chain), not the bare -1. Both always create a new instance; the bare form
// additionally leaves placement to REAPER's ambient FX-chain insert point, which a drop
// onto an FX container/chain-window moves — so the index handed to TrackFX_SetPreset and
// the instance just created stop denoting the same FX and the capture never lands. The
// bare-form retry keeps the reference path alive if the positional form is ever refused.
// = first in chain), not the bare -1 — this form is documented in the SDK header. Both
// always create a new instance; the bare form additionally leaves placement to REAPER's
// ambient FX-chain insert point, which a drop onto an FX container/chain-window is
// suspected (unconfirmed by experiment) to move — if so, the index handed to
// TrackFX_SetPreset and the instance just created would stop denoting the same FX and the
// capture would never land. The bare-form retry keeps the reference path alive if the
// positional form is ever refused.
// recFX = false: a normal track FX chain instance, not a record/monitoring FX.
const int insertPos = TrackFX_GetCount(track);
int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
/*instantiate=*/-1000 - insertPos);
if (fxIndex < 0)
// Only retry with the bare form when the chain is PROVABLY unchanged (count still
// insertPos): a negative return with the count grown means the positional add DID create
// an instance and just reported -1 — retrying then would add a SECOND instance, leaving
// the first orphaned (no preset applied, unreachable for rollback), which is exactly the
// all-or-nothing violation this contract forbids.
if (fxIndex < 0 && TrackFX_GetCount(track) == insertPos)
fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false, /*instantiate=*/-1);
// u8string() gives UTF-8 bytes on MSVC (not ACP-converted), so a temp dir under
+32 -7
View File
@@ -297,14 +297,32 @@ void onMouseMove(int x, int y) {
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.releaseInternalDrag) return;
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.
@@ -318,8 +336,7 @@ void onMouseMove(int x, int y) {
g_panel.dragPrimaryId.clear();
invalidatePanel();
if (handoff.startOsDrag)
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
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
@@ -372,6 +389,7 @@ void resetDragState() {
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:
@@ -388,12 +406,19 @@ void onLBtnUp(int x, int y) {
//
// 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.
// 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;
if (singleCapture) {
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);
+6
View File
@@ -320,6 +320,12 @@ struct PanelState {
// when the pointer is not over an FX button.
MediaTrack* instrumentDropTrack = nullptr;
// Latched once an OsDrag hand-off attempt for THIS gesture resolves to "cannot hand off"
// (empty/unresolvable payload, or the OS isn't ready) — skips re-running
// resolveDragPathsForOs (an fs::exists per sample) and the readiness probe on every
// subsequent move while the drag stays alive. Cleared by resetDragState.
bool dragOsHandoffBlocked = false;
// Authoritative tail setting lives in ReaSamplerSession, not here; panel reads it for
// drawing and mutates via footer click / scroll-wheel. bankPanelTailSetting is the
// capture actions' read seam.