Merge drag-out hand-off fix: keep the drag internal while the pointer is over REAPER

This commit is contained in:
2026-08-03 16:45:14 -04:00
14 changed files with 271 additions and 110 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ is owned by other directories and only skinned here.
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Also owns `pointerOverHostWindow`, the window-ownership test behind `ui::DropContext::pointerOffHost` (`core/ui/CLAUDE.md` owns the hand-off rule it feeds) — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only. Its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
+30
View File
@@ -244,6 +244,27 @@ bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
return true;
}
bool pointerOverHostWindow(int screenX, int screenY) {
// Process identity, not window identity: REAPER's arrange, its floating FX and VST editor
// windows, its menus and its docker chrome are separate top-level HWNDs with no common
// ancestor, so no GetAncestor/GetParent walk reaches them all — but they are windows of the
// process this extension is loaded into, bar the bridged-plugin case the header names.
// Mouse capture does not affect WindowFromPoint: it hit-tests the desktop window tree, not
// input routing. It does skip hidden and DISABLED windows, so a REAPER window disabled behind
// a modal dialog raised mid-drag (a REAPER timer or another extension can raise one — capture
// is not a modality lock) would fall through to whatever sits beneath and read as off-host.
// Accepted, unverified: not reproduced, and nothing here was checked against REAPER's own
// dialog behavior.
const POINT pt{screenX, screenY};
const HWND hwnd = WindowFromPoint(pt);
// Not an unknown resolved toward hand-off: a null return is the desktop tree stating there is
// no window at that point at all, which is a determinate "no host window here".
if (!hwnd) return false;
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
return pid == GetCurrentProcessId();
}
} // namespace reasampler
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
@@ -277,6 +298,15 @@ bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
return !absolutePaths.empty();
}
// SWELL's WindowFromPoint walks only THIS process's window list (SWELL_topwindows on
// generic/GDK, [NSApp orderedWindows] on macOS — swell-wnd-generic.cpp / swell-wnd.mm), so a
// non-null return already carries the process-ownership meaning Win32 needs GetWindowThread-
// ProcessId for, and a point over any other application returns null.
bool pointerOverHostWindow(int screenX, int screenY) {
POINT pt{screenX, screenY};
return WindowFromPoint(pt) != nullptr;
}
} // namespace reasampler
#endif
+15 -3
View File
@@ -1,7 +1,8 @@
#pragma once
// drag_out_win — the OS/COM initiation half of native OS drag-out: hands a resolved
// existing-file path list to the OS's drag-drop machinery so the user can drop bank
// samples into Explorer / another app / another DAW. The pure gesture-boundary
// drag_out_win — the host/OS boundary of native OS drag-out: whether the pointer has
// crossed it, whether the OS is ready to take the drag, and the hand-off itself, which
// gives a resolved existing-file path list to the OS's drag-drop machinery so the user can
// drop bank samples into Explorer / another app / another DAW. The pure gesture-boundary
// decision + path-list assembly live in drag_out.*.
//
// COPY-ONLY is STRUCTURAL on Windows: DoDragDrop's effect mask is DROPEFFECT_COPY
@@ -32,4 +33,15 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
// probe HGLOBAL immediately; SWELL exposes no probe, so macOS/Linux reduces to the non-empty check.
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
// True iff a window belonging to the host (REAPER) process sits under the screen point — the
// POSITIVE proof ui::DropContext::pointerOffHost is the negation of (that member owns the
// evidence rule). Nearly every REAPER surface qualifies whether or not GetThingFromPoint can
// name it, because the extension runs inside that process.
// The exception, and it ships: a plugin set to "Run as separate/dedicated process" draws its
// UI out of reaper_host32/64.exe, and WindowFromPoint descends to the deepest child — so this
// reports false over a bridged plugin's editor although the pointer never left REAPER. The
// law's hand-off gate absorbs that by also requiring REAPER's own hit-test to name nothing.
// Side-effect-free; safe on every mouse-move.
bool pointerOverHostWindow(int screenX, int screenY);
} // namespace reasampler
+1 -1
View File
@@ -83,7 +83,7 @@ DropProbe probeDropTarget(int screenX, int screenY) {
// doc-comment says so), so the track and the surface are two independent facts and both
// are reported. The pure classifier owns every token rule.
out.track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
out.surface = classifyReaperSurface(info, out.track != nullptr);
out.surface = classifyReaperSurface(info);
return out;
}
+4 -2
View File
@@ -29,12 +29,14 @@ namespace reasampler {
// (ui::decideDropClass) turns this plus the payload size into an outcome.
struct DropProbe {
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
ui::ReaperSurface surface = ui::ReaperSurface::OffReaper;
ui::ReaperSurface surface = ui::ReaperSurface::Other;
};
// Wraps GetThingFromPoint and hands its (info string, track) pair to the pure
// Wraps GetThingFromPoint: its track comes back as-is, its info string goes to the pure
// wire::classifyReaperSurface. Cheap enough to run on every mouse-move, but the panel
// evaluates it only OUTSIDE its own client rect — the internal drag never pays for it.
// Reports only what REAPER named; no output here implies an exit (ui::DropContext::
// pointerOffHost owns that rule).
DropProbe probeDropTarget(int screenX, int screenY);
// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its
+11 -2
View File
@@ -199,6 +199,10 @@ LiveDrop resolveLiveDrop(int x, int y) {
const DropProbe probe = probeDropTarget(sp.x, sp.y);
ctx.surface = probe.surface;
ctx.haveTrack = probe.track != nullptr;
// Two probes because they answer two independent questions: what REAPER named, and
// whether we are still inside REAPER at all. The law's hand-off gate reads both — see
// ui::DropContext::pointerOffHost.
ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y);
out.track = probe.track;
out.screenX = sp.x;
}
@@ -219,11 +223,12 @@ LiveDrop resolveLiveDrop(int x, int y) {
// loop, so the internal drag must be fully wound down first, and only once the payload is known
// to be hand-off-able. This runs on every qualifying move — do not memoize a failed attempt.
//
// Residual (accepted): once the pointer has left REAPER, dragging back INTO a REAPER window
// Residual (accepted): once the pointer has genuinely left every REAPER window, dragging back IN
// mid-modal-loop delivers a CF_HDROP to REAPER's own file-import drop target rather than to our
// gesture law. NOT confirmed by experiment — inferred from REAPER's handling of external file
// drops, and the inferred outcome (an item at the drop point) coincides with what our own
// arrange path would have done.
// arrange path would have done. This is now the ONLY way to see that import ghost mid-drag;
// reaching it from a surface still inside REAPER was the defect pointerOffHost closed.
void handOffToOs() {
// Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let
// the pure rule couple the two side effects: an unresolvable payload leaves the internal
@@ -242,6 +247,10 @@ void handOffToOs() {
invalidatePanel();
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
// The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes
// onLBtnUp's not-dragging branch and never restores the arrow — do it here instead.
SetCursor(LoadCursor(nullptr, IDC_ARROW));
}
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,