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.
This commit is contained in:
2026-07-26 20:02:46 -04:00
parent 73f81ffbb7
commit 34df2563e4
7 changed files with 738 additions and 1 deletions
+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