Merge Ψ-W1-T4: resolve drop targets per move, not once
# Conflicts: # src/shell/actions/CLAUDE.md
This commit is contained in:
@@ -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 `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), 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`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; 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.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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. insert.cpp does NOT share this assumption: it
|
||||
// resets the cursor before every track's insert specifically to stay independent of
|
||||
// cursor-advance behavior (insert.cpp:18-20, "this doesn't matter either way"). This call
|
||||
// site is the first in the tree to depend on it.
|
||||
// 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.
|
||||
// D. GetSet_ArrangeView2's one-pixel span [screenX, screenX+1) reads the time at that column.
|
||||
// The header documents only the all-zero span (screen_x_start==screen_x_end==0) as the
|
||||
// "whole view" special case; the per-column reading for any other span is inferred, not
|
||||
// documented.
|
||||
// E. InsertMedia's int return isn't SDK-documented; treated conservatively as 0=failure,
|
||||
// nonzero=success — performArrangeDrop counts only the latter.
|
||||
|
||||
#include "shell/actions/arrange_drop_win.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#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; kept separate here because insert.cpp sits in the capture
|
||||
// pillar, outside this track's surface fence — not a ruling that the two should never share a
|
||||
// helper, just not this track's call to make.
|
||||
//
|
||||
// CountSelectedTracks/GetSelectedTrack both skip the master track (SDK header), so a user with
|
||||
// the master selected loses that selection across the drop — pre-existing behavior inherited
|
||||
// from insert.cpp's identical pair, not fixed here.
|
||||
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 with a one-pixel span [screenX, screenX+1) is assumed to read the time at
|
||||
// that column — inferred, not documented (assumption D in the file header). The SDK's ONLY
|
||||
// documented special form is screen_x_start==screen_x_end==0 (both zero) for "the whole
|
||||
// arrange view's start/end time"; a zero-width span at a nonzero column (e.g. screenX,
|
||||
// screenX) is NOT that special case, so the +1 here is precautionary rather than required.
|
||||
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's
|
||||
// return isn't SDK-documented (assumption E); treated conservatively as 0=failure, so a
|
||||
// REAPER-side refusal is reflected in the count and in the undo label, not silent.
|
||||
if (InsertMedia(path.c_str(), mode) != 0) ++inserted;
|
||||
}
|
||||
|
||||
// A fixed stack buffer, not std::string concatenation: the prior shape built the label with
|
||||
// std::to_string + `+` between the restore below and Undo_EndBlock2, so a bad_alloc there
|
||||
// would leave an unbalanced undo block open. snprintf here removes the allocation outright.
|
||||
char label[64];
|
||||
std::snprintf(label, sizeof(label), "ReaSampler: drop %d %s onto arrange", inserted,
|
||||
inserted == 1 ? "capture" : "captures");
|
||||
|
||||
restoreSelectedTracks(priorSelection);
|
||||
SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false);
|
||||
|
||||
// extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block.
|
||||
Undo_EndBlock2(nullptr, label, -1);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,34 @@
|
||||
#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 GetSet_ArrangeView2's one-pixel-column reading —
|
||||
// inferred behavior, not SDK-documented (see the .cpp's assumption D). Times left of project
|
||||
// start clamp to 0.
|
||||
double arrangeTimeAtScreenX(int screenX);
|
||||
|
||||
// Places every path in `absolutePaths` on `track`, inside ONE undo block. The first lands at
|
||||
// `time`; whether REAPER's own cursor advance lands the rest end-to-end, or stacks them at one
|
||||
// position instead, is assumption B in the .cpp (visible, one Ctrl-Z away, either way). The
|
||||
// drop time is assumed to honor the project's snap setting (assumption C). The caller's track
|
||||
// selection and edit-cursor position are restored before returning. Returns the number of files
|
||||
// InsertMedia reported inserting successfully — its return isn't SDK-documented; treated as
|
||||
// 0=failure (assumption E in the .cpp).
|
||||
int performArrangeDrop(MediaTrack* track, double time,
|
||||
const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+188
-134
@@ -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,102 @@ 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; // 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
|
||||
@@ -259,80 +356,25 @@ 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);
|
||||
// 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);
|
||||
|
||||
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();
|
||||
applyDropCue(cueForDropClass(live.cls)); // OsHandoff -> OsOwned, a documented no-op cue
|
||||
if (live.cls == DropClass::OsHandoff) {
|
||||
handOffToOs();
|
||||
return;
|
||||
}
|
||||
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 +411,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 +457,54 @@ 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. 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) {
|
||||
// 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;
|
||||
// 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);
|
||||
|
||||
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. 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;
|
||||
}
|
||||
// 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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -132,6 +135,7 @@ using ui::hitTestTabStrip;
|
||||
using ui::menuButtonReserve;
|
||||
using ui::modeSegmentEnabled;
|
||||
using ui::navigate;
|
||||
using ui::needsSurfaceProbe;
|
||||
using ui::roleColor;
|
||||
using ui::stripActionPrefix;
|
||||
using ui::tagButtonEnabled;
|
||||
@@ -324,14 +328,9 @@ 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 (core/ui/CLAUDE.md: "the
|
||||
// drag-out law is per-move and stateless"). 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
|
||||
|
||||
Reference in New Issue
Block a user