Ψ-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
+9 -3
View File
@@ -4,8 +4,9 @@
The bindable action families routed through REAPER's `command_id`/`gaccel`/
`hookcommand` contract (Design View toggle actions, bank actions, the prune
action, and the shared registration plumbing/table), plus the OS drag-out and
FX-drop shells, plus the extension-side ingest-through-the-bank shell. This is
action, and the shared registration plumbing/table), plus the three drag-out
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
extension-side ingest-through-the-bank shell. This is
where user-facing REAPER actions and OS-level drag/drop live; the underlying
mutation logic (bank verbs, prune's orphan computation, view-mode reconciliation)
is owned by other directories and only skinned here.
@@ -15,6 +16,10 @@ is owned by other directories and only skinned here.
- **Ingest is an extension act; the instrument is a read-only bank consumer.** Any
instrument code path that captures, imports, inserts a timeline item, or writes
back into the bank is a bug — the instrument reads and plays only.
- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and
it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
capture/placement separation forbids a CAPTURE placing an item; a deliberate drop
is placement on demand. No other module here may grow an `InsertMedia` call.
- **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the
existing capture add-path and assigns the resulting `Sample` id to the target
instance; it never places anything on the timeline — capture/placement
@@ -33,7 +38,8 @@ is owned by other directories and only skinned here.
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win`FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `instrument_drop_win`instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `classifyReaperSurface`, which owns every token rule), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2` over a one-pixel span) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track, laid out sequentially, in ONE undo block, with the caller's track selection and edit cursor restored). This is the one shell in this directory that DOES place timeline items — and it is legitimate because the user dragged them there, the same class of act as `RunInsertSelected`. It never captures and never writes the bank.
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.**
## Gotchas
+105
View File
@@ -0,0 +1,105 @@
// arrange_drop_win.cpp — see arrange_drop_win.h. main.cpp owns the API pointers; this TU gets
// them extern via the WANT list.
//
// Runtime assumptions, all DAW-verifiable and none confirmed by the SDK header:
// A. InsertMedia base mode 0 targets the sole selected track and inserts at the edit cursor;
// SetOnlyTrackSelected isolates that track first (the same pair insert.cpp relies on).
// B. InsertMedia advances the edit cursor past the media it added. That advance IS the
// multi-file layout: the cursor is deliberately not reset between files, so N captures
// land end to end. If REAPER does not advance it, they stack at one position instead —
// visible and one Ctrl-Z away, never silent.
// C. SnapToGrid honors the project's snap-enabled toggle. The header documents no
// snap-enabled query for the arrange, so a drop taken with snapping OFF is the test that
// settles it.
#include "shell/actions/arrange_drop_win.h"
#include <string>
#include <vector>
#include "core/capture/insert_plan.h" // computeInsertMode — the ONE InsertMedia bitfield owner
#include "reaper_plugin.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_GetSet_ArrangeView2
#define REAPERAPI_WANT_InsertMedia
#define REAPERAPI_WANT_SetEditCurPos
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_SetTrackSelected
#define REAPERAPI_WANT_SnapToGrid
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
using capture::computeInsertMode;
using capture::InsertOptions;
namespace {
// Selection snapshot/restore, so a drop leaves the user's track selection exactly as it found
// it. Mirrors insert.cpp's pair; that TU belongs to the capture pillar and this one to the drag
// chain, so they are kept separate rather than sharing a helper across the seam.
std::vector<MediaTrack*> snapshotSelectedTracks() {
const int n = CountSelectedTracks(nullptr); // nullptr = active project
std::vector<MediaTrack*> tracks;
tracks.reserve(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i) tracks.push_back(GetSelectedTrack(nullptr, i));
return tracks;
}
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
if (tracks.empty()) return; // nothing was selected; leave whatever the drop selected
SetOnlyTrackSelected(tracks[0]);
for (std::size_t i = 1; i < tracks.size(); ++i) SetTrackSelected(tracks[i], true);
}
} // namespace
double arrangeTimeAtScreenX(int screenX) {
double start = 0.0, end = 0.0;
// isSet=false reads the arrange times spanned by [screen_x_start, screen_x_end); a
// one-pixel span therefore reads the time at that column. (The all-zero span is the SDK's
// documented "whole view" form, which is why the span here must not be empty.)
GetSet_ArrangeView2(nullptr, false, screenX, screenX + 1, &start, &end);
return start < 0.0 ? 0.0 : start;
}
int performArrangeDrop(MediaTrack* track, double time,
const std::vector<std::string>& absolutePaths) {
if (!track || absolutePaths.empty()) return 0;
const std::vector<MediaTrack*> priorSelection = snapshotSelectedTracks();
const double priorCursor = GetCursorPosition();
const int mode = computeInsertMode(InsertOptions{}); // current track, native length, no stretch
// One undo block around the whole drop (every item plus the selection/cursor restore) so a
// single Ctrl-Z returns the project to exactly its pre-drop state.
Undo_BeginBlock2(nullptr);
SetOnlyTrackSelected(track);
SetEditCurPos(SnapToGrid(nullptr, time), /*moveview=*/false, /*seekplay=*/false);
int inserted = 0;
for (const std::string& path : absolutePaths) {
// No cursor reset between files — see assumption B in the file header.
InsertMedia(path.c_str(), mode);
++inserted;
}
restoreSelectedTracks(priorSelection);
SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false);
// extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block.
const std::string label = "ReaSampler: drop " + std::to_string(inserted) +
(inserted == 1 ? " capture" : " captures") + " onto arrange";
Undo_EndBlock2(nullptr, label.c_str(), -1);
return inserted;
}
} // namespace reasampler
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// arrange_drop_win — the arrange outcome of the panel's drag-out gesture: places the dragged
// bank captures on the timeline at the track and time under the pointer.
//
// LOAD-BEARING: this is USER-INITIATED PLACEMENT, the same class of act as RunInsertSelected.
// Root CLAUDE.md's "capture and placement are separate acts" forbids a CAPTURE placing an item;
// a deliberate drop onto the timeline is placement on demand, and the user chose the spot.
// Nothing here captures or writes the bank.
#include <string>
#include <vector>
// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without pulling
// in the SDK.
class MediaTrack;
namespace reasampler {
// The arrange time under a screen X, via REAPER's own pixel-column -> time mapping. Times left
// of project start clamp to 0.
double arrangeTimeAtScreenX(int screenX);
// Places every path in `absolutePaths` on `track`, the first at `time` and each subsequent one
// after the previous (REAPER's own multi-file drop shape), inside ONE undo block. The drop time
// honors the project's snap setting. The caller's track selection and edit-cursor position are
// restored before returning. Returns the number of files handed to REAPER.
int performArrangeDrop(MediaTrack* track, double time,
const std::vector<std::string>& absolutePaths);
} // namespace reasampler
+9 -12
View File
@@ -12,7 +12,7 @@
#include <vector>
#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
#include "core/wire/instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier
#include "core/wire/instrument_drop.h" // classifyReaperSurface — the PURE, unit-tested classifier
#include "reaper_plugin.h"
@@ -33,7 +33,7 @@ using version::vstPluginName;
using wire::decideDropOutcome;
using wire::DropAttempt;
using wire::DropOutcome;
using wire::infoNamesFxHotspot;
using wire::classifyReaperSurface;
namespace {
@@ -76,17 +76,14 @@ std::filesystem::path writeTempPreset(const std::vector<std::uint8_t>& bytes) {
} // namespace
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
FxDropTarget out;
DropProbe probeDropTarget(int screenX, int screenY) {
DropProbe out;
char info[256] = {0};
// A non-empty info OR a non-null track means the point is over REAPER's own UI;
// a null track with empty info means the pointer has left REAPER entirely.
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
out.track = track;
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
// The hotspot is either the FX chain/floating window ("fx_*") OR the FX-button family of
// the track/mixer panel ("tcp.fx*"/"mcp.fx*"). The pure classifier owns the rule.
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
// GetThingFromPoint may return a null track together with a valid info string (its own
// doc-comment says so), so the track and the surface are two independent facts and both
// are reported. The pure classifier owns every token rule.
out.track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
out.surface = classifyReaperSurface(info, out.track != nullptr);
return out;
}
+14 -14
View File
@@ -9,33 +9,33 @@
//
// LOAD-BEARING: an EXPLICIT user placement-of-the-player gesture — adds a READER of
// the bank on a track, pointed at an already-captured sample. NEVER captures, NEVER
// writes the bank, NEVER inserts a timeline item. The only writes are a new FX
// instance + its component state, both wrapped in one undo block (one Ctrl-Z), plus
// a transient .vstpreset deleted before returning.
// writes the bank, NEVER inserts a timeline item (the drag's arrange outcome is a
// separate shell, arrange_drop_win). The only writes are a new FX instance + its
// component state, both wrapped in one undo block (one Ctrl-Z), plus a transient
// .vstpreset deleted before returning.
#include <cstdint>
#include <vector>
#include "core/ui/drag_out.h" // ReaperSurface
// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without
// pulling in the SDK.
class MediaTrack;
namespace reasampler {
struct FxDropTarget {
// One evaluation of what sits under a screen point. Carries no verdict — the pure law
// (ui::decideDropClass) turns this plus the payload size into an outcome.
struct DropProbe {
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
bool overFxHotspot = false; // specifically over this track's FX button/chain surface
bool valid() const { return track != nullptr && overFxHotspot; }
ui::ReaperSurface surface = ui::ReaperSurface::OffReaper;
};
// Wraps GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/
// "mcp.fx*" for the TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and
// floating windows). `overReaperUi` is true when the point is over REAPER's own UI
// at all; `overFxHotspot` is true only for a genuine FX-bearing surface (decided by
// the pure instrument_drop::infoNamesFxHotspot).
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
// Wraps GetThingFromPoint and hands its (info string, track) pair to the pure
// wire::classifyReaperSurface. Cheap enough to run on every mouse-move, but the panel
// evaluates it only OUTSIDE its own client rect — the internal drag never pays for it.
DropProbe probeDropTarget(int screenX, int screenY);
// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its
// component state. Wraps add + apply in one REAPER undo block. All-or-nothing: if
+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