From 34df2563e46b977b87c695a6ef81f50bf9cef01e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:02:46 -0400 Subject: [PATCH 1/2] feat(m11): native OS drag-out of bank samples (copy-only) Pure drag_out (gesture boundary + path-list assembly) + Windows OLE CF_HDROP / SWELL file-list shell; bank_panel hands off when a dragged selection leaves the client area. COPY-ONLY mask; internal drag intact. --- CMakeLists.txt | 21 +++- src/bank_panel.cpp | 61 ++++++++++ src/drag_out.cpp | 48 ++++++++ src/drag_out.h | 118 +++++++++++++++++++ src/drag_out_win.cpp | 254 ++++++++++++++++++++++++++++++++++++++++ src/drag_out_win.h | 43 +++++++ tests/test_drag_out.cpp | 194 ++++++++++++++++++++++++++++++ 7 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 src/drag_out.cpp create mode 100644 src/drag_out.h create mode 100644 src/drag_out_win.cpp create mode 100644 src/drag_out_win.h create mode 100644 tests/test_drag_out.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e2cbbb..1feb278 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -315,6 +315,20 @@ target_include_directories(provenance PUBLIC src) add_library(action_buttons STATIC src/action_buttons.cpp) 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). # --------------------------------------------------------------------------- @@ -413,6 +427,10 @@ add_executable(action_buttons_tests tests/test_action_buttons.cpp) target_link_libraries(action_buttons_tests PRIVATE action_buttons) 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). # --------------------------------------------------------------------------- @@ -450,8 +468,9 @@ add_library(reaper_reasampler MODULE src/actions.cpp src/bank_book.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}) # 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' diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 756470d..a2453c6 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -48,6 +48,8 @@ #include "action_buttons.h" // pure button-strip layout + label format (M11) #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 "bank_book.h" #include "bank_grid.h" @@ -1616,6 +1618,34 @@ std::vector focusedSelectionIds() { 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 resolveDragPathsForOs() { + std::vector 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 -------------------------------------------------------------- // // SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu @@ -2076,6 +2106,37 @@ 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). + 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 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); invalidatePanel(); } diff --git a/src/drag_out.cpp b/src/drag_out.cpp new file mode 100644 index 0000000..158ca32 --- /dev/null +++ b/src/drag_out.cpp @@ -0,0 +1,48 @@ +// drag_out — pure implementation. See drag_out.h. NO REAPER / SWELL / OS / vendor. + +#include "drag_out.h" + +#include + +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& resolved) { + PathList out; + std::unordered_set 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 diff --git a/src/drag_out.h b/src/drag_out.h new file mode 100644 index 0000000..3e50c1f --- /dev/null +++ b/src/drag_out.h @@ -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 +#include + +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 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& resolved); + +} // namespace reasampler diff --git a/src/drag_out_win.cpp b/src/drag_out_win.cpp new file mode 100644 index 0000000..d6c9685 --- /dev/null +++ b/src/drag_out_win.cpp @@ -0,0 +1,254 @@ +// 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 +#include // DoDragDrop, IDataObject, IDropSource, ReleaseStgMedium +#include // DROPFILES, CF_HDROP +#include + +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& 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 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(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(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(reinterpret_cast(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(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(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_); + void* dst = GlobalLock(copy); + if (src && dst) std::memcpy(dst, src, sz); + if (src) GlobalUnlock(hdrop_); + if (dst) 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& 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 + +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& absolutePaths) { + if (absolutePaths.empty() || !panelHwnd) return false; + + std::vector 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(panelHwnd), nullptr, + ptrs.data(), static_cast(ptrs.size()), nullptr); + return true; // fire-and-forget; SWELL owns the drag from here (no accept/cancel return) +} + +} // namespace reasampler + +#endif diff --git a/src/drag_out_win.h b/src/drag_out_win.h new file mode 100644 index 0000000..04ccb6b --- /dev/null +++ b/src/drag_out_win.h @@ -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 +#include + +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& absolutePaths); + +} // namespace reasampler diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp new file mode 100644 index 0000000..a02af47 --- /dev/null +++ b/tests/test_drag_out.cpp @@ -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 +#include +#include + +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; +} From 4f51758ad6a5f97e33f6a976d11159f2b06e01df Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 20:06:55 -0400 Subject: [PATCH 2/2] fix(drag_out): return E_OUTOFMEMORY on GlobalLock failure in HDropDataObject::GetData --- src/drag_out_win.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/drag_out_win.cpp b/src/drag_out_win.cpp index d6c9685..e555f24 100644 --- a/src/drag_out_win.cpp +++ b/src/drag_out_win.cpp @@ -138,10 +138,12 @@ public: 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 (src && dst) std::memcpy(dst, src, sz); - if (src) GlobalUnlock(hdrop_); - if (dst) GlobalUnlock(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;