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:
+95
-31
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user