Merge m11-drag-out: native OS drag-out of bank samples (copy-only)

This commit is contained in:
2026-07-26 20:08:20 -04:00
7 changed files with 740 additions and 1 deletions
+20 -1
View File
@@ -315,6 +315,20 @@ target_include_directories(provenance PUBLIC src)
add_library(action_buttons STATIC src/action_buttons.cpp) add_library(action_buttons STATIC src/action_buttons.cpp)
target_include_directories(action_buttons PUBLIC src) target_include_directories(action_buttons PUBLIC src)
# ---------------------------------------------------------------------------
# 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11
# native-OS-drag-out decision core: the gesture-boundary decision (drag state +
# pointer + panel rect -> internal drag / OS drag / none — the invariant-#4 seam
# that keeps the internal bank-to-bank drag byte-identical) and the drag path-list
# assembly (resolved sample paths -> de-duped, existing-only absolute path list with
# an explicit skip-missing / skip-unresolved policy). Split out so the boundary +
# set algebra are unit-tested outside the DAW; the OLE DoDragDrop / SWELL file-list
# initiation (drag_out_win) and the bank_panel gesture hook are DAW-verified. Mirror
# of action_buttons / mode_switch.
# ---------------------------------------------------------------------------
add_library(drag_out STATIC src/drag_out.cpp)
target_include_directories(drag_out PUBLIC src)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER). # 3) Standalone tests for the pure modules (run without launching REAPER).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -413,6 +427,10 @@ add_executable(action_buttons_tests tests/test_action_buttons.cpp)
target_link_libraries(action_buttons_tests PRIVATE action_buttons) target_link_libraries(action_buttons_tests PRIVATE action_buttons)
add_test(NAME action_buttons_tests COMMAND action_buttons_tests) add_test(NAME action_buttons_tests COMMAND action_buttons_tests)
add_executable(drag_out_tests tests/test_drag_out.cpp)
target_link_libraries(drag_out_tests PRIVATE drag_out)
add_test(NAME drag_out_tests COMMAND drag_out_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -450,8 +468,9 @@ add_library(reaper_reasampler MODULE
src/actions.cpp src/actions.cpp
src/bank_book.cpp src/bank_book.cpp
src/owned_manifest.cpp src/owned_manifest.cpp
src/drag_out_win.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or # OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' # "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
+61
View File
@@ -48,6 +48,8 @@
#include "action_buttons.h" // pure button-strip layout + label format (M11) #include "action_buttons.h" // pure button-strip layout + label format (M11)
#include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path)
#include "drag_out.h" // pure gesture-boundary decision + path-list assembly (M11)
#include "drag_out_win.h" // OLE / SWELL drag-out initiation seam (M11)
#include "app_version.h" // channelCommandId — compose the named-command lookup string (M11) #include "app_version.h" // channelCommandId — compose the named-command lookup string (M11)
#include "bank_book.h" #include "bank_book.h"
#include "bank_grid.h" #include "bank_grid.h"
@@ -1616,6 +1618,34 @@ std::vector<std::string> focusedSelectionIds() {
return ids; return ids;
} }
// Resolves the ARMED drag payload (g_panel.dragSampleIds, from g_panel.dragSourceBankId) to
// the absolute, existing-file path list for a native OS drag-out (M11). Reuses the SAME M4
// path machinery the panel uses for audition/insert (resolveBankFile over the current
// project dir) — no temp copies; the drag points straight at the on-disk bank files. Each
// id is looked up in its SOURCE bank's index (the payload's origin, not the focused region,
// which can differ once the pointer roams), resolved, stat'd, then handed to the pure
// drag_out::assemblePathList for dedupe + skip-missing/unresolved policy. Read-only: no
// mutation of sample / index / selection (invariant #2).
std::vector<std::string> resolveDragPathsForOs() {
std::vector<ResolvedSample> resolved;
BankBook* b = book();
if (!b) return {};
const BankIndex* idx = b->index(g_panel.dragSourceBankId);
if (!idx) return {};
const std::string projectDir = currentProjectDir();
resolved.reserve(g_panel.dragSampleIds.size());
for (const std::string& sid : g_panel.dragSampleIds) {
const Sample* s = idx->query(sid);
if (!s) continue; // stale id — the pure layer would skip it anyway; nothing to resolve
ResolvedSample rs;
rs.absolutePath = resolveBankFile(projectDir, s->relativePath);
rs.fileExists = !rs.absolutePath.empty() && fs::exists(fs::path(rs.absolutePath));
resolved.push_back(std::move(rs));
}
return assemblePathList(resolved).paths;
}
// --- Popup menus -------------------------------------------------------------- // --- Popup menus --------------------------------------------------------------
// //
// SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu // SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu
@@ -2076,6 +2106,37 @@ void onMouseMove(int x, int y) {
} }
} }
if (g_panel.dragging) { 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).
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) {
// 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();
// Reset internal drag state and release capture NOW: DoDragDrop runs its own
// modal loop and takes over mouse capture, so the internal drag must be fully
// wound down first (no stale dragging/dropKind, no lingering SetCapture). A
// cancelled/empty OS drag therefore leaves the panel in a clean, no-op state
// (invariant #2 — nothing mutated).
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
g_panel.dragArmed = false;
g_panel.dragging = false;
g_panel.dropKind = DropKind::None;
g_panel.dropBankId.clear();
invalidatePanel();
// Empty path list -> nothing draggable (all stale/missing); do not start a drag.
if (!paths.empty())
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
return;
}
updateDropTarget(x, y); updateDropTarget(x, y);
invalidatePanel(); invalidatePanel();
} }
+48
View File
@@ -0,0 +1,48 @@
// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor.
#include "drag_out.h"
#include <unordered_set>
namespace reasampler {
namespace {
// Half-open point-in-rect (matches the panel's other hit-tests: [x, x+w) x [y, y+h)).
bool insideClient(int px, int py, const PanelClientRect& c) {
return px >= c.x && px < c.x + c.width &&
py >= c.y && py < c.y + c.height;
}
} // namespace
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;
}
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
PathList out;
std::unordered_set<std::string> seen;
seen.reserve(resolved.size());
for (const ResolvedSample& s : resolved) {
if (s.absolutePath.empty()) { // shell could not resolve it
++out.skippedUnresolved;
continue;
}
if (!s.fileExists) { // stale index entry, file gone
++out.skippedMissing;
continue;
}
if (!seen.insert(s.absolutePath).second) { // already emitted this path
++out.skippedDuplicate;
continue;
}
out.paths.push_back(s.absolutePath);
}
return out;
}
} // namespace reasampler
+118
View File
@@ -0,0 +1,118 @@
#pragma once
// drag_out — the REAPER-free / OS-free decision logic behind the bank_panel's native OS
// drag-out (Milestone 11, the final polish point). Two pure concerns live here so they are
// unit-tested outside the DAW (CLAUDE.md §load-bearing split); the OLE / SWELL initiation
// and the bank_panel gesture hook stay in the shell (drag_out_win.* + bank_panel.cpp).
//
// 1. GESTURE BOUNDARY (invariant #4 — do not regress the internal drag). The panel
// already runs an INTERNAL drag: press a selected cell, cross a threshold, drop onto
// a pool/banks region or a tab to move/copy the samples between banks. That drag lives
// entirely INSIDE the panel client rect. The OS drag is a DISTINCT gesture with a
// distinct, discoverable boundary: while a drag is armed with samples in the payload,
// the moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
// the payload is being dragged out to another window / Explorer / another DAW. Inside
// the client area it stays internal; with no armed samples there is no drag at all.
// This function is that decision, pure over (drag state + pointer + panel rect).
//
// 2. PATH-LIST ASSEMBLY. The OS drop carries absolute file paths (Windows CF_HDROP /
// macOS file-list pasteboard). Turning the armed sample ids into that path list —
// resolving each id to its already-on-disk bank file, de-duping, and applying an
// explicit skip-missing-file policy — is pure string work over a resolver the shell
// supplies (the shell owns the REAPER project-dir read + resolveBankFile; this module
// owns the set algebra and the result contract). NO temp files: the bank files already
// exist; the list points straight at them (COPY-ONLY is enforced at the OS layer — see
// drag_out_win — never by relocating or copying bytes here).
//
// PURE MODULE: NO REAPER types, NO SWELL, NO OS/OLE, NO vendor/ includes. Standard library
// only. Builds and unit-tests without REAPER. Mirror of action_buttons / mode_switch.
#include <string>
#include <vector>
namespace reasampler {
// --- Gesture boundary ---------------------------------------------------------
// The panel's client rectangle in its own client coordinates (top-left origin, the SWELL/
// LICE convention). width/height are the extents; a point (px, py) is INSIDE when
// x <= px < x + width and y <= py < y + height (half-open, matching the panel's other
// hit-tests so the edge is claimed consistently).
struct PanelClientRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const PanelClientRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
// The live drag state the shell tracks, reduced to what the boundary decision needs:
// 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.)
struct DragState {
bool dragging = false; // threshold crossed; a drag is in progress
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id
};
// 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
};
// 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.
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
const DragState& state);
// --- Path-list assembly -------------------------------------------------------
// One armed sample reduced to what path assembly needs: the resolved ABSOLUTE file path
// the shell computed for it (empty when the shell could not resolve it — e.g. no project
// dir / empty relative path). The shell resolves each via the SAME machinery the panel
// already uses for audition/insert (resolveBankFile over the current project dir), so the
// drag points at the real bank file — no temp copy.
struct ResolvedSample {
std::string absolutePath; // resolved absolute path, or "" when unresolvable
bool fileExists = false; // shell stat() result — drives the skip-missing policy
};
// The outcome of assembling the drag's path list: the de-duped, existing-only absolute
// paths to hand to the OS, plus explicit tallies so the shell can decide whether to
// initiate at all (an empty `paths` means nothing draggable — do NOT start a drag).
struct PathList {
std::vector<std::string> paths; // de-duped, existing files, in first-seen order
int skippedMissing = 0; // resolved but file did not exist (skip policy)
int skippedUnresolved = 0; // shell could not resolve a path at all
int skippedDuplicate = 0; // same absolute path seen more than once
};
// Assembles the drag path list from the resolved samples (in selection order).
// Policy (all explicit, all tested):
// * SKIP-MISSING: a sample whose file does not exist on disk is skipped (counted in
// skippedMissing) — a stale index entry must never put a dangling path on the OS
// clipboard. This is the deliberate skip policy the brief asks be made explicit.
// * SKIP-UNRESOLVED: an empty absolutePath (shell could not resolve) is skipped
// (skippedUnresolved) — same reasoning, no empty entry reaches the OS.
// * DEDUPE: the same absolute path appearing twice (two index entries, one file — the
// cross-bank copy case) yields ONE CF_HDROP entry (skippedDuplicate counts the extras),
// so the OS never sees a duplicate drop path. First occurrence wins; order preserved.
// * EMPTY SELECTION: an empty input yields an empty PathList (all tallies zero) — the
// shell reads paths.empty() and does not start a drag.
// Comparison is exact-string (the shell normalizes slashes/case upstream if it wants
// case-insensitive dedup on Windows — the pure layer does not guess a platform rule).
PathList assemblePathList(const std::vector<ResolvedSample>& resolved);
} // namespace reasampler
+256
View File
@@ -0,0 +1,256 @@
// drag_out_win — OS/COM initiation of native OS drag-out (M11). See drag_out_win.h.
//
// Windows path (primary): a hand-rolled minimal IDataObject exposing exactly one format,
// CF_HDROP, plus a minimal IDropSource, handed to OLE DoDragDrop with a COPY-ONLY effect
// mask. We roll our own rather than pull in a helper because the object is tiny (one
// format, one medium) and the copy-only guarantee must be structural and auditable in one
// place. mac/linux route to SWELL's file-list drag behind the same seam.
//
// Compiled into the reaper_reasampler MODULE. No REAPER API is used here (pure OS/COM); it
// is a leaf the bank_panel calls.
#include "drag_out_win.h"
#ifdef _WIN32
#include <windows.h>
#include <ole2.h> // DoDragDrop, IDataObject, IDropSource, ReleaseStgMedium
#include <shlobj.h> // DROPFILES, CF_HDROP
#include <cstring>
namespace reasampler {
namespace {
// Builds the CF_HDROP HGLOBAL: a DROPFILES header followed by a double-null-terminated
// list of wide (UTF-16) absolute paths. Windows CF_HDROP requires backslash separators and
// a trailing extra NUL after the final path's NUL. Returns nullptr on allocation failure or
// empty input. Ownership transfers to the STGMEDIUM (freed by ReleaseStgMedium / the OS).
HGLOBAL buildHDrop(const std::vector<std::string>& paths) {
if (paths.empty()) return nullptr;
// 1) Convert each UTF-8 path to wide, normalizing '/' -> '\\' (the panel stores paths
// slash-normalized for its own resolution; CF_HDROP wants native backslashes).
std::vector<std::wstring> wide;
wide.reserve(paths.size());
std::size_t totalChars = 0; // characters incl. each path's terminating NUL
for (const std::string& p : paths) {
if (p.empty()) continue;
const int need = MultiByteToWideChar(CP_UTF8, 0, p.c_str(), -1, nullptr, 0);
if (need <= 0) continue; // unconvertible path — skip rather than emit garbage
std::wstring w(static_cast<std::size_t>(need), L'\0');
MultiByteToWideChar(CP_UTF8, 0, p.c_str(), -1, &w[0], need);
// `need` includes the NUL; drop it from the string length, we re-add it in the buffer.
if (!w.empty() && w.back() == L'\0') w.pop_back();
for (wchar_t& c : w) if (c == L'/') c = L'\\';
totalChars += w.size() + 1; // + the per-path NUL
wide.push_back(std::move(w));
}
if (wide.empty()) return nullptr;
totalChars += 1; // the extra double-NUL terminator after the last path
const SIZE_T bytes = sizeof(DROPFILES) + totalChars * sizeof(wchar_t);
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, bytes);
if (!h) return nullptr;
auto* df = static_cast<DROPFILES*>(GlobalLock(h));
if (!df) { GlobalFree(h); return nullptr; }
df->pFiles = sizeof(DROPFILES); // offset to the file list
df->fWide = TRUE; // wide (UTF-16) path list
auto* dst = reinterpret_cast<wchar_t*>(reinterpret_cast<char*>(df) + sizeof(DROPFILES));
for (const std::wstring& w : wide) {
std::memcpy(dst, w.c_str(), (w.size() + 1) * sizeof(wchar_t)); // incl. NUL
dst += w.size() + 1;
}
*dst = L'\0'; // double-NUL terminates the list (ZEROINIT already did, explicit for clarity)
GlobalUnlock(h);
return h;
}
// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always
// request the copy cursor. This is the standard textbook drop source — no custom feedback.
class DropSource final : public IDropSource {
public:
// IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
if (riid == IID_IUnknown || riid == IID_IDropSource) {
*ppv = static_cast<IDropSource*>(this);
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
ULONG STDMETHODCALLTYPE Release() override {
const ULONG r = --refs_;
if (r == 0) delete this;
return r;
}
// IDropSource
HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override {
if (escapePressed) return DRAGDROP_S_CANCEL;
if (!(keyState & MK_LBUTTON)) return DRAGDROP_S_DROP; // released -> drop
return S_OK; // keep dragging
}
HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override {
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
}
private:
ULONG refs_ = 1;
};
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
// built once at construction and cloned on each GetData call (OLE owns the returned medium).
class HDropDataObject final : public IDataObject {
public:
explicit HDropDataObject(HGLOBAL hdrop) : hdrop_(hdrop) {}
~HDropDataObject() { if (hdrop_) GlobalFree(hdrop_); }
// IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
if (riid == IID_IUnknown || riid == IID_IDataObject) {
*ppv = static_cast<IDataObject*>(this);
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs_; }
ULONG STDMETHODCALLTYPE Release() override {
const ULONG r = --refs_;
if (r == 0) delete this;
return r;
}
// IDataObject — the two that matter for a drag source.
HRESULT STDMETHODCALLTYPE GetData(FORMATETC* fmt, STGMEDIUM* med) override {
if (!fmt || !med) return E_INVALIDARG;
if (!isHDrop(*fmt)) return DV_E_FORMATETC;
if (!hdrop_) return E_UNEXPECTED;
// Clone the HGLOBAL so the caller (OLE / target) owns an independent copy; our
// hdrop_ stays valid for repeat GetData calls and is freed in the dtor.
const SIZE_T sz = GlobalSize(hdrop_);
HGLOBAL copy = GlobalAlloc(GMEM_MOVEABLE, sz);
if (!copy) return E_OUTOFMEMORY;
void* src = GlobalLock(hdrop_);
if (!src) { GlobalFree(copy); return E_OUTOFMEMORY; }
void* dst = GlobalLock(copy);
if (!dst) { GlobalUnlock(hdrop_); GlobalFree(copy); return E_OUTOFMEMORY; }
std::memcpy(dst, src, sz);
GlobalUnlock(hdrop_);
GlobalUnlock(copy);
med->tymed = TYMED_HGLOBAL;
med->hGlobal = copy;
med->pUnkForRelease = nullptr; // caller releases via ReleaseStgMedium
return S_OK;
}
HRESULT STDMETHODCALLTYPE QueryGetData(FORMATETC* fmt) override {
return (fmt && isHDrop(*fmt)) ? S_OK : DV_E_FORMATETC;
}
// The remainder are the standard "not supported for a simple source" stubs.
HRESULT STDMETHODCALLTYPE GetDataHere(FORMATETC*, STGMEDIUM*) override { return E_NOTIMPL; }
HRESULT STDMETHODCALLTYPE GetCanonicalFormatEtc(FORMATETC*, FORMATETC* out) override {
if (out) out->ptd = nullptr;
return E_NOTIMPL;
}
HRESULT STDMETHODCALLTYPE SetData(FORMATETC*, STGMEDIUM*, BOOL) override { return E_NOTIMPL; }
HRESULT STDMETHODCALLTYPE EnumFormatEtc(DWORD dir, IEnumFORMATETC** out) override {
if (dir == DATADIR_GET && out) {
FORMATETC fe = hdropFormat();
return SHCreateStdEnumFmtEtc(1, &fe, out);
}
return E_NOTIMPL;
}
HRESULT STDMETHODCALLTYPE DAdvise(FORMATETC*, DWORD, IAdviseSink*, DWORD*) override {
return OLE_E_ADVISENOTSUPPORTED;
}
HRESULT STDMETHODCALLTYPE DUnadvise(DWORD) override { return OLE_E_ADVISENOTSUPPORTED; }
HRESULT STDMETHODCALLTYPE EnumDAdvise(IEnumSTATDATA**) override {
return OLE_E_ADVISENOTSUPPORTED;
}
private:
static FORMATETC hdropFormat() {
FORMATETC fe{};
fe.cfFormat = CF_HDROP;
fe.ptd = nullptr;
fe.dwAspect = DVASPECT_CONTENT;
fe.lindex = -1;
fe.tymed = TYMED_HGLOBAL;
return fe;
}
static bool isHDrop(const FORMATETC& fe) {
return fe.cfFormat == CF_HDROP &&
(fe.tymed & TYMED_HGLOBAL) &&
fe.dwAspect == DVASPECT_CONTENT;
}
ULONG refs_ = 1;
HGLOBAL hdrop_ = nullptr;
};
} // namespace
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty()) return false;
// REAPER's main thread is already OLE-initialized (it hosts OLE drag targets), so we do
// NOT call OleInitialize here — a nested OleInitialize on an already-initialized STA is
// harmless-but-unnecessary, and OleUninitialize pairing across a REAPER-owned apartment
// is the kind of thing that bites. DoDragDrop works on the already-initialized STA.
HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false;
auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop
auto* source = new DropSource();
DWORD effect = 0;
// COPY-ONLY (invariant #1): the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is
// NEVER offered, so no drop target can relocate (delete) the bank file — only prune
// deletes bank bytes (Phase R boundary).
const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
source->Release();
data->Release(); // frees the source HGLOBAL via HDropDataObject's dtor
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
}
} // namespace reasampler
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
#include "wdltypes.h"
#include "swell/swell.h"
#include <vector>
namespace reasampler {
// SWELL provides a file-list drag surface (SWELL_InitiateDragDropOfFileList, verified in
// vendor/WDL/WDL/swell/swell-functions.h). It takes a C-string array + count and initiates
// a copy-style file drag from the given window. Unlike OLE it exposes no per-source effect
// mask, so the copy-only guarantee rests on SWELL's copy semantics rather than an explicit
// DROPEFFECT_COPY mask — an honest platform difference, not a faked equivalence. Windows is
// the exact-control path (D5: Windows is the shipping target).
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty() || !panelHwnd) return false;
std::vector<const char*> ptrs;
ptrs.reserve(absolutePaths.size());
for (const std::string& p : absolutePaths) ptrs.push_back(p.c_str());
// srcrect null: SWELL positions the drag image at the current event. No custom icon.
SWELL_InitiateDragDropOfFileList(reinterpret_cast<HWND>(panelHwnd), nullptr,
ptrs.data(), static_cast<int>(ptrs.size()), nullptr);
return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return)
}
} // namespace reasampler
#endif
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// drag_out_win — the OS/COM initiation half of native OS drag-out (Milestone 11). The pure
// gesture-boundary decision and path-list assembly live in drag_out.*; THIS is the platform
// shell that hands a resolved, existing-file path list to the operating system's drag-drop
// machinery so the user can drop bank samples into Explorer / another app / another DAW.
//
// ONE seam, platform-forked inside the .cpp:
// * Windows (primary — Daniel's target): OLE DoDragDrop with a minimal IDataObject
// carrying CF_HDROP (absolute paths, double-null-terminated wide list) and a minimal
// IDropSource. COPY-ONLY is STRUCTURAL: the IDataObject offers DROPEFFECT_COPY and the
// effect mask passed to DoDragDrop is DROPEFFECT_COPY alone — MOVE is never offered, so
// no target can pull the bank file out of the bank folder (invariant #1: a move would
// delete bank bytes, and per the Phase R boundary ONLY prune deletes files).
// * macOS/Linux (SWELL): SWELL_InitiateDragDropOfFileList (verified present in
// vendor/WDL/WDL/swell/swell-functions.h) behind the same seam. SWELL's file-list drag
// is a copy-style file drag; it exposes no per-source effect mask the way OLE does, so
// the copy-only guarantee there rests on SWELL's copy semantics rather than an explicit
// mask — noted honestly, not faked. Windows is where the mask control is exact.
//
// NON-DESTRUCTIVE (invariant #2): initiating a drag reads nothing but the path list and
// mutates no sample / index / selection. A cancelled or failed drag changes nothing — the
// OS layer here neither writes ext-state nor touches the book.
#include <string>
#include <vector>
struct HWND__; // avoid dragging windows.h into every includer; the shell casts as needed.
namespace reasampler {
// Initiates a native OS drag-out of `absolutePaths` (already resolved, existing, de-duped —
// the pure drag_out::assemblePathList output) from the panel window `panelHwnd`. COPY-ONLY;
// see the header note. A no-op when the path list is empty (nothing draggable — the caller
// checks this too, but the guard is repeated here so a direct call is safe).
//
// BLOCKING on Windows: OLE DoDragDrop runs its own modal message loop until the drop or
// cancel, then returns — the caller's gesture state should be reset AFTER this returns.
// Returns true if a drop was accepted (DROPEFFECT_COPY), false on cancel / failure /
// empty input. The return is advisory (a failed drag is visible by nothing happening —
// the caller does not surface an error, per the brief's no-console-output constraint).
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
} // namespace reasampler
+194
View File
@@ -0,0 +1,194 @@
// Standalone tests for reasampler::drag_out — no REAPER, no test framework. Same fast loop
// as the sibling pure tests (action_buttons / mode_switch et al.): assert the gesture-
// boundary decision and the path-list assembly directly.
//
// Covers (M11 drag-out brief §test cases):
// * Gesture boundary: inside-panel drag stays Internal; leaving the client area with
// armed samples -> OsDrag; no armed samples (or not dragging) -> None; half-open edge
// behavior; re-entry back inside returns to Internal (position-only decision).
// * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing,
// skip-unresolved, empty selection, order preservation, mixed tallies.
#include "../src/drag_out.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- Gesture boundary ---------------------------------------------------------
static const PanelClientRect kPanel{0, 0, 400, 300};
// A drag with samples, pointer well inside the client rect -> the existing internal drag
// (invariant #4: inside-panel drag stays internal, unchanged).
static void testInsidePanelStaysInternal() {
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
CHECK(decideGesture(0, 0, kPanel, s) == DragGesture::Internal); // top-left corner
CHECK(decideGesture(399, 299, kPanel, s) == DragGesture::Internal); // last inside px
}
// A drag with samples whose pointer has left the client rect (any edge) -> OS drag.
static void testLeavingClientAreaIsOsDrag() {
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
CHECK(decideGesture(-1, 150, kPanel, s) == DragGesture::OsDrag); // left of panel
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag); // right edge (x+w)
CHECK(decideGesture(200, -5, kPanel, s) == DragGesture::OsDrag); // above
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag); // below (y+h)
CHECK(decideGesture(1000, 1000, kPanel, s) == DragGesture::OsDrag);// far outside
}
// The half-open boundary: x+width and y+height are OUTSIDE (OsDrag), the pixel just inside
// is Internal — matches the panel's other hit-tests so the edge is claimed consistently.
static void testBoundaryHalfOpen() {
DragState s{true, true};
CHECK(decideGesture(399, 150, kPanel, s) == DragGesture::Internal);
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag);
CHECK(decideGesture(200, 299, kPanel, s) == DragGesture::Internal);
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag);
}
// No armed samples -> None regardless of position (an empty-payload drag never goes to the
// OS). Not dragging -> None even with samples (the shell asks only mid-drag, but the guard
// is explicit).
static void testNoDragOrNoSamplesIsNone() {
CHECK(decideGesture(1000, 1000, kPanel, DragState{true, false}) == DragGesture::None);
CHECK(decideGesture(200, 150, kPanel, DragState{true, false}) == DragGesture::None);
CHECK(decideGesture(1000, 1000, kPanel, DragState{false, true}) == DragGesture::None);
CHECK(decideGesture(200, 150, kPanel, DragState{false, false}) == DragGesture::None);
}
// Re-entry: the decision is position-only, so a pointer that left (OsDrag) and came back
// inside reads Internal again. (The shell, having handed off to the modal OS loop, simply
// stops asking — but the pure function must not be stateful.)
static void testReentryReturnsInternal() {
DragState s{true, true};
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); // left
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); // re-entered
}
// A non-zero panel origin (the client rect need not sit at 0,0) — the boundary tracks the
// rect, not the absolute axes.
static void testOffsetPanelRect() {
PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
DragState s{true, true};
CHECK(decideGesture(100, 60, p, s) == DragGesture::Internal);
CHECK(decideGesture(49, 60, p, s) == DragGesture::OsDrag); // just left of origin
CHECK(decideGesture(150, 60, p, s) == DragGesture::OsDrag); // x+width
CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin
}
// --- Path-list assembly -------------------------------------------------------
static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; }
static ResolvedSample missing(const std::string& p) { return ResolvedSample{p, false}; }
static ResolvedSample unresolved() { return ResolvedSample{"", false}; }
static void testSinglePath() {
PathList l = assemblePathList({ok("C:/proj/bank/a.wav")});
CHECK(l.paths.size() == 1);
CHECK(l.paths[0] == "C:/proj/bank/a.wav");
CHECK(l.skippedMissing == 0);
CHECK(l.skippedUnresolved == 0);
CHECK(l.skippedDuplicate == 0);
}
// Multiple distinct paths pass through in selection order (order preserved).
static void testMultiPreservesOrder() {
PathList l = assemblePathList({ok("b.wav"), ok("a.wav"), ok("c.wav")});
CHECK(l.paths.size() == 3);
CHECK(l.paths[0] == "b.wav");
CHECK(l.paths[1] == "a.wav");
CHECK(l.paths[2] == "c.wav");
}
// Two index entries resolving to the SAME file (the cross-bank copy case — one file, two
// entries) yield ONE CF_HDROP path; the extra is counted, first occurrence wins.
static void testDedupeSamePath() {
PathList l = assemblePathList({ok("x.wav"), ok("y.wav"), ok("x.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "x.wav");
CHECK(l.paths[1] == "y.wav");
CHECK(l.skippedDuplicate == 1);
}
// A stale index entry (file gone from disk) is skipped — never a dangling path on the OS
// clipboard.
static void testSkipMissing() {
PathList l = assemblePathList({ok("a.wav"), missing("gone.wav"), ok("b.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "a.wav");
CHECK(l.paths[1] == "b.wav");
CHECK(l.skippedMissing == 1);
}
// An unresolvable entry (empty path — no project dir / empty relative) is skipped; note an
// empty path is skippedUnresolved, NOT skippedMissing, even though fileExists is false.
static void testSkipUnresolved() {
PathList l = assemblePathList({ok("a.wav"), unresolved(), ok("b.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.skippedUnresolved == 1);
CHECK(l.skippedMissing == 0);
}
// Empty selection -> empty list, all tallies zero (the shell reads paths.empty() and does
// not start a drag).
static void testEmptySelection() {
PathList l = assemblePathList({});
CHECK(l.paths.empty());
CHECK(l.skippedMissing == 0);
CHECK(l.skippedUnresolved == 0);
CHECK(l.skippedDuplicate == 0);
}
// All-skipped selection -> empty list with the right tallies (nothing draggable).
static void testAllSkippedYieldsEmpty() {
PathList l = assemblePathList({missing("g1.wav"), unresolved(), missing("g2.wav")});
CHECK(l.paths.empty());
CHECK(l.skippedMissing == 2);
CHECK(l.skippedUnresolved == 1);
}
// Mixed: every skip class at once, plus a survivor, with independent tallies.
static void testMixedTallies() {
PathList l = assemblePathList({
ok("keep.wav"), // survives
missing("gone.wav"), // skippedMissing
unresolved(), // skippedUnresolved
ok("keep.wav"), // skippedDuplicate
ok("also.wav"), // survives
});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "keep.wav");
CHECK(l.paths[1] == "also.wav");
CHECK(l.skippedMissing == 1);
CHECK(l.skippedUnresolved == 1);
CHECK(l.skippedDuplicate == 1);
}
int main() {
testInsidePanelStaysInternal();
testLeavingClientAreaIsOsDrag();
testBoundaryHalfOpen();
testNoDragOrNoSamplesIsNone();
testReentryReturnsInternal();
testOffsetPanelRect();
testSinglePath();
testMultiPreservesOrder();
testDedupeSamePath();
testSkipMissing();
testSkipUnresolved();
testEmptySelection();
testAllSkippedYieldsEmpty();
testMixedTallies();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}