Merge Ψ-W1-T4: resolve drop targets per move, not once

# Conflicts:
#	src/shell/actions/CLAUDE.md
This commit is contained in:
2026-08-01 20:57:55 -04:00
17 changed files with 908 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 `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
+129
View File
@@ -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
+34
View File
@@ -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
+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