S17 drop-and-load onto track FX button; S13 editor drop-accept (relay degraded)

S17: drag_out InstrumentDrop gesture + instrument_drop blob reusing the
instrument's own serializer; bank_panel FX hover-track + add-VST/vst_chunk
inject. S13 relay deferred (read-only bridge) — editor shows drop affordance.
This commit is contained in:
2026-07-27 03:52:26 -04:00
parent 06494c654e
commit 26e2bf2fc6
13 changed files with 779 additions and 54 deletions
+95 -31
View File
@@ -62,6 +62,8 @@
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
#include "instrument_drop.h" // pure buildInstrumentDropChunk — the vst_chunk blob (S17)
#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17)
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5)
@@ -321,6 +323,15 @@ struct PanelState {
CardGesture cardGesture = CardGesture::None;
int dragTargetSlot = -1;
// --- S17 drop-and-load (InstrumentDrop) -----------------------------------
// While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an
// InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX
// hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the
// dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last
// resolved FX-hotspot track (null when the pointer is not over an FX button) — read on
// release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched.
MediaTrack* instrumentDropTrack = nullptr;
// --- Tail-mode toggle -----------------------------------------------------
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
@@ -2917,16 +2928,52 @@ void onMouseMove(int x, int y) {
}
}
if (g_panel.dragging) {
// M11 gesture boundary (invariant #4): while a drag with samples is under way, the
// moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
// hand the payload to the native OS drag. Inside the client area it stays the
// existing internal bank-to-bank drag, byte-identical. The boundary decision is the
// pure drag_out::decideGesture (drag state + pointer + client rect).
// M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the
// pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant
// #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture
// splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an
// InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a
// pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays
// Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint.
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
if (decideGesture(x, y, client, st) == DragGesture::OsDrag) {
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
st.singleCapture = (g_panel.dragSampleIds.size() == 1);
// Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can
// only arise there) and only for a single-capture payload — the SDK hit-test is skipped
// on the common internal-drag path so it costs nothing there. The screen conversion is
// Windows-only (D5); resolveFxDropTarget owns the REAPER hit query.
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; the highlight is REAPER's own FX-button
// hover feedback under the pointer (the drop is driven on button-up). We keep the
// internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does
// NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the
// panel does not also paint a bank-drop cue while the drag is out over a track.
g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
g_panel.dropKind = DropKind::None;
g_panel.dropBankId.clear();
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) {
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
const std::vector<std::string> paths = resolveDragPathsForOs();
@@ -2995,31 +3042,45 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId,
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
void onLBtnUp(int x, int y) {
if (g_panel.dragging) {
updateDropTarget(x, y);
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
const CardGesture g = g_panel.cardGesture;
// S17 drop-and-load: a release while hover-tracking 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 L7 in-grid / cross-bank
// drop (the pointer is out over a track, not over a bank region). Single-capture only (the
// gesture never armed for a multi payload), so dragSampleIds.front() is the capture.
if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
const std::string sampleId = g_panel.dragSampleIds.front();
const std::string chunk = buildInstrumentDropChunk(sampleId);
performInstrumentDrop(g_panel.instrumentDropTrack, chunk);
// Read-only over the bank + arrange: the ONLY mutations are the new FX instance +
// its state (both undoable in performInstrumentDrop). No book change, no ext-state,
// no dirty-mark here.
} else {
updateDropTarget(x, y);
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
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;
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
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);
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;
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
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 -> no-op drop (dead space, or same-bank gap resolved to None).
@@ -3041,6 +3102,7 @@ void onLBtnUp(int x, int y) {
g_panel.cardGesture = CardGesture::None;
g_panel.dragTargetSlot = -1;
g_panel.dragPrimaryId.clear();
g_panel.instrumentDropTrack = nullptr; // S17: clear the FX hotspot after the release
invalidatePanel();
}
@@ -3169,6 +3231,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
g_panel.cardGesture = CardGesture::None;
g_panel.dragTargetSlot = -1;
g_panel.dragPrimaryId.clear();
g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on teardown
g_panel.hovered = Hover{};
g_panel.tooltipShown = false;
g_panel.hwnd = nullptr;
@@ -3226,6 +3289,7 @@ void closePanel() {
stopAudition();
g_panel.selection = Selection{};
g_panel.dragArmed = g_panel.dragging = false;
g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on close
unregisterAccel();
if (g_panel.hwnd) {
DockWindowRemove(g_panel.hwnd);
+7 -1
View File
@@ -19,7 +19,13 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state) {
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
return insideClient(px, py, client) ? DragGesture::Internal : DragGesture::OsDrag;
if (insideClient(px, py, client)) return DragGesture::Internal;
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is
// still over REAPER's own UI is an instrument drop (heading for a track's FX button);
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
// unchanged M11 OS drag-out.
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
return DragGesture::OsDrag;
}
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
+31 -9
View File
@@ -52,28 +52,50 @@ struct PanelClientRect {
// whether a drag is currently active (threshold crossed) and whether the armed payload
// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag
// for this decision — the shell only asks once a drag is under way.)
//
// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without
// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical):
// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag
// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag
// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural
// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture
// is "make ONE capture a playable instrument", so a multi payload is out of contract here.
// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the
// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER
// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra).
// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets
// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag.
struct DragState {
bool dragging = false; // threshold crossed; a drag is in progress
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id
bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop)
bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied)
};
// What the shell should do with the drag given the current pointer position.
enum class DragGesture {
None, // no drag under way, or an empty payload — do nothing
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag
OsDrag, // dragging with samples, pointer left the client area — hand off to the OS
None, // no drag under way, or an empty payload — do nothing
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag
InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI —
// the shell hover-tracks the TCP FX button and, on release, adds a
// ReaSampler 9000 instance preloaded with the dragged capture.
OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS
};
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`.
// * Not dragging (or no armed samples): None — the shell ignores the move.
// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged
// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical).
// * Dragging with samples, pointer OUTSIDE the client rect: OsDrag — the samples are
// leaving the panel; the shell initiates the native OS drag with the resolved paths.
// The boundary is the client rect edge: the internal drag never targets outside it, so
// crossing it is an unambiguous, discoverable OS-drag trigger. Re-entry is the shell's
// concern (the OS drag loop is modal once begun); this function reports OsDrag purely from
// position, so a shell that has already handed off simply will not ask again.
// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop —
// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights.
// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left
// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the
// native OS drag with the resolved paths.
// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary
// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks
// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11
// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only
// + state-only (no hidden state), so re-entry back inside returns Internal.
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state);
+108
View File
@@ -0,0 +1,108 @@
// instrument_drop — pure implementation. See instrument_drop.h.
// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer.
#include "instrument_drop.h"
#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
namespace reasampler {
namespace {
constexpr char kB64Alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// -1 = not a base64 char; index by unsigned byte. Built once.
int b64Value(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
} // namespace
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId) {
// The ONE fact the drop carries: this capture is the instance's selection. Everything
// else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same
// ComponentState a browser click would produce. serializeComponentState is the
// instrument's own writer (the single source of truth for the byte layout), so this is
// NOT a parallel encoder — it IS the instrument's encoder.
ComponentState cs;
cs.selectionId = sampleId;
return serializeComponentState(cs);
}
std::string buildInstrumentDropChunk(const std::string& sampleId) {
return encodeBase64(instrumentDropStateBytes(sampleId));
}
std::string encodeBase64(const std::vector<std::uint8_t>& bytes) {
std::string out;
out.reserve(((bytes.size() + 2) / 3) * 4);
std::size_t i = 0;
const std::size_t n = bytes.size();
while (i + 3 <= n) {
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
static_cast<std::uint32_t>(bytes[i + 2]);
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
out.push_back(kB64Alphabet[triple & 0x3F]);
i += 3;
}
const std::size_t rem = n - i;
if (rem == 1) {
const std::uint32_t triple = static_cast<std::uint32_t>(bytes[i]) << 16;
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back('=');
out.push_back('=');
} else if (rem == 2) {
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8);
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
out.push_back('=');
}
return out;
}
std::vector<std::uint8_t> decodeBase64(const std::string& b64) {
std::vector<std::uint8_t> out;
if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws)
out.reserve((b64.size() / 4) * 3);
for (std::size_t i = 0; i < b64.size(); i += 4) {
const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3];
const int v0 = b64Value(static_cast<unsigned char>(c0));
const int v1 = b64Value(static_cast<unsigned char>(c1));
if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty
// Padding is only legal in the last two positions of the last quad.
const bool pad2 = (c2 == '=');
const bool pad3 = (c3 == '=');
if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad
if (pad2 && !pad3) return {}; // "=X" is malformed
std::uint32_t triple = (static_cast<std::uint32_t>(v0) << 18) |
(static_cast<std::uint32_t>(v1) << 12);
out.push_back(static_cast<std::uint8_t>((triple >> 16) & 0xFF));
if (!pad2) {
const int v2 = b64Value(static_cast<unsigned char>(c2));
if (v2 < 0) return {};
triple |= static_cast<std::uint32_t>(v2) << 6;
out.push_back(static_cast<std::uint8_t>((triple >> 8) & 0xFF));
if (!pad3) {
const int v3 = b64Value(static_cast<unsigned char>(c3));
if (v3 < 0) return {};
triple |= static_cast<std::uint32_t>(v3);
out.push_back(static_cast<std::uint8_t>(triple & 0xFF));
}
}
}
return out;
}
} // namespace reasampler
+65
View File
@@ -0,0 +1,65 @@
#pragma once
// instrument_drop — the PURE blob-construction core of S17 drop-and-load.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK,
// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested
// outside the DAW — the same "small pure builder + round-trip proof" pattern as
// assignment_request / provenance.
//
// -- What it is (the S17 seam, extension side) --------------------------------
//
// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on
// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism
// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the
// extension writes the instance's component state directly via
// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)
// with the dragged capture PRE-SELECTED.
//
// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded
// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a
// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the
// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel
// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState
// (the single source of truth for the byte layout — the same function the processor's
// getState calls), then base64-encodes the result. The shared-writer requirement (both
// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the
// instrument's format changes, this module changes with it because it CALLS it.
//
// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see
// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk").
#include <cstdint>
#include <string>
#include <vector>
namespace reasampler {
// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk").
// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects.
// The resulting ComponentState is the instrument's default face with just this one capture
// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly
// what a fresh instance would hold after the user clicked that capture in the browser. The
// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics,
// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify).
//
// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects
// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure
// contract is defined.
//
// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes).
std::string buildInstrumentDropChunk(const std::string& sampleId);
// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them
// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert
// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what
// its setState expects. Not called by the shell (which uses the base64 form).
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the
// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the
// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector
// (never throws) — the test asserts a clean decode, and the shell never decodes.
std::string encodeBase64(const std::vector<std::uint8_t>& bytes);
std::vector<std::uint8_t> decodeBase64(const std::string& b64);
} // namespace reasampler
+83
View File
@@ -0,0 +1,83 @@
// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list).
#include "instrument_drop_win.h"
#include <cstring>
#include <string>
#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
#include "reaper_plugin.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetThingFromPoint
#define REAPERAPI_WANT_TrackFX_AddByName
#define REAPERAPI_WANT_TrackFX_SetNamedConfigParm
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// GetThingFromPoint's info string prefixes (verified against reaper_plugin_functions.h:
// "Updates infoOut with information such as 'arrange', 'fx_chain', 'fx_0' ... If a track
// panel is hit, string will begin with 'tcp' or 'mcp' or 'tcp.mute' etc"). The FX region
// reports "fx_chain" (the FX list area) or "fx_N" (a specific FX button). We treat either
// as the FX hotspot — the S17 drop target.
bool infoNamesFxHotspot(const char* info) {
return std::strncmp(info, "fx_", 3) == 0;
}
} // namespace
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
FxDropTarget out;
char info[256] = {0};
// GetThingFromPoint returns the track under the point (may be null for a non-track thing)
// and fills `info` with what was hit. A non-empty info OR a non-null track means the point
// is over REAPER's own UI; a null track with an empty info means the pointer has left
// REAPER entirely (over another app / the desktop) — the OsDrag boundary.
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
out.track = track;
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
return out;
}
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) {
if (!track || chunkBase64.empty()) return false;
// The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000
// beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST
// factory display name derives from) keeps the pairing invariant intact — a beta extension
// drops the beta VST, a stable extension the stable VST — with no literal to drift.
const std::string fxName = "VST3:" + vstPluginName();
// One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the
// FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
Undo_BeginBlock2(nullptr);
// Negative `instantiate` => always create a NEW instance (verified in the header). recFX
// = false: a normal track FX chain instance, not a record/monitoring FX.
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
/*instantiate=*/-1);
bool ok = false;
if (fxIndex >= 0) {
// Inject the instrument's OWN component-state blob (the dragged capture pre-selected)
// via the documented vst_chunk write-parm. The blob was built by the shared writer
// (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState),
// so these bytes are exactly what ReaSampler 9000's setState accepts.
ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str());
}
// The undo label reflects the placement-of-the-player framing (not a capture, not an insert).
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
return ok;
}
} // namespace reasampler
+56
View File
@@ -0,0 +1,56 @@
#pragma once
// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure blob construction in
// instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track +
// its TCP FX-button hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
// 9000 instance to that track and injects the dragged capture as its component state.
//
// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*,
// Undo_*), so DAW-verified, not unit-tested; the pure decision + blob it drives are CTest'd.
//
// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player
// gesture — it adds a READER of the bank on a track and points it at one already-captured
// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The
// only writes are: a new FX instance on the target track + that instance's own component
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z.
#include <string>
// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK
// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees.
class MediaTrack;
namespace reasampler {
// The result of hit-testing a screen point during a live InstrumentDrop drag.
struct FxDropTarget {
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 TCP FX-button/-chain region
// A valid drop target: a resolved track whose FX hotspot is under the pointer.
bool valid() const { return track != nullptr && overFxHotspot; }
};
// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps
// GetThingFromPoint, whose info string tells us what was hit ("tcp"/"mcp" for a track panel,
// "fx_chain"/"fx_N" for the FX area/button). `overReaperUi` is the shell-supplied predicate
// the pure drag_out::decideGesture consumes (true when the point is over REAPER's own UI —
// i.e. GetThingFromPoint returned a track OR a recognizable non-track thing, false when the
// pointer has left REAPER entirely). `overFxHotspot` is true when the info string names the
// FX region specifically — the S17 "FX-button hotspot vs. whole TCP" question is resolved to
// the FX hotspot (the discoverable, unambiguous target), decided here from the SDK's own
// hit-test string rather than a home-grown geometry guess.
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and inject `chunkBase64`
// (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays
// the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one
// REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX
// was added and the chunk written), false on any failure (add returned -1, or the chunk write
// was rejected). A false return leaves at most the added FX (no partial-state confusion — the
// caller surfaces nothing; a failed add is visible by nothing happening). NEVER inserts a
// timeline item; the ONLY mutations are the FX instance + its state, both undoable.
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64);
} // namespace reasampler
+58 -1
View File
@@ -28,6 +28,7 @@
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept
#include "wdltypes.h"
#include "lice/lice.h"
@@ -218,6 +219,12 @@ void ReaSamplerEditor::onSyncTimer() {
refreshFromBank();
invalidate();
}
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
if (dropHintTicks_ > 0) {
--dropHintTicks_;
invalidate();
}
}
#endif // _WIN32
@@ -522,6 +529,10 @@ void ReaSamplerEditor::attachedToParent() {
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
DragAcceptFiles(childHwnd_, TRUE);
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
// lifetime — created here, killed in removedFromParent — so an instance whose editor
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
@@ -697,6 +708,19 @@ void ReaSamplerEditor::paint(HDC hdc) {
paintBrowser(&bmp, w, h);
}
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
// LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerH = (std::min)(kTitleHeight + 8, h);
Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH};
LICE_FillRect(&bmp, banner.left, banner.top, banner.width(), banner.height(),
kColTabActiveBg, 1.0f, 0);
drawTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
kRgbText);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
@@ -706,7 +730,16 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
const char* msg = samples_.empty()
? "No captures in this project yet - capture audio into the bank to play it here."
: "No captures in this bank filter. Choose another bank tab above.";
drawTextCentered(bmp, area, msg, kRgbDim);
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
// discoverable here regardless of whether a drop ever lands on THIS window.
Rect primary{area.left, area.top, area.right, area.top + area.height() / 2};
Rect hint{area.left, primary.bottom, area.right, area.bottom};
drawTextCentered(bmp, primary, msg, kRgbDim);
drawTextCentered(bmp,
hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
kRgbDim);
}
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
@@ -1559,6 +1592,20 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
dropHintTicks_ = 6;
#ifdef _WIN32
invalidate();
#endif
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self =
@@ -1612,6 +1659,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
self->invalidate();
}
return 0;
case WM_DROPFILES: {
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
// read/ingest the paths (the instrument never ingests — the relay to the extension is
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
DragFinish(drop);
if (self) self->onFilesDropped(static_cast<int>(count));
return 0;
}
case WM_TIMER:
if (self && wParam == kSyncTimerId) self->onSyncTimer();
return 0;
+18
View File
@@ -114,6 +114,13 @@ private:
void onMouseWheel(int delta); // S12 browser scroll (wheel)
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
void onFilesDropped(int droppedCount);
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
// a new assignment request -> apply as this instance's selection) and, when anything
@@ -212,6 +219,17 @@ private:
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
// --- S12 browser scroll + search (transient UI state, never persisted) --------
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
std::string searchQuery_; // type-to-filter narrow; "" = no search