Merge plugin-editor drop: a card dragged onto a third-party plugin's editor is handed to the OS as a file drop

This commit is contained in:
2026-09-25 14:15:47 -04:00
19 changed files with 992 additions and 135 deletions
+2 -1
View File
@@ -27,6 +27,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag_os.cpp
${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
@@ -62,7 +63,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out window_origin instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine — a link edge to it here means the design drifted.
+26 -11
View File
@@ -86,16 +86,30 @@ L7 sub-pass, 2026-07-27):
- **The drag-out law is per-move and stateless.** The class is resolved from the
current pointer on every move and again at the release point; nothing is latched
between evaluations. That is what makes every transition reversible and what
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
REAPER entirely is the one irreversible transition, because the OS hand-off goes
modal — so it must be PROVEN (a window-ownership test in the shell), never inferred
from a hit-test token REAPER could not name; an unrecognised surface *inside* REAPER
refuses. That proof is necessary but NOT sufficient: a plugin bridged into
`reaper_host*.exe` fails the process test while still being REAPER's own UI, so the
hand-off additionally requires REAPER's hit-test to have named nothing — a recognised
token can only ever veto the hand-off, never produce one. A first-move class lock and
a drag-lifetime "cannot hand off" latch both existed here and were removed — do not
reintroduce either.
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. The OS
hand-off goes modal, so it must be PROVEN by a window test in the shell, never
inferred from a hit-test token REAPER could not name; an unrecognised surface
*inside* REAPER refuses. There are exactly two proofs, each necessary but NOT
sufficient, each paired with a hit-test condition:
- **Leaving REAPER** (process ownership). A plugin bridged into `reaper_host*.exe`
fails the process test while still being inside REAPER, so this route also requires
REAPER's hit-test to have named nothing — here a recognised token can only VETO.
- **A plugin's own editor** (code ownership, `window_origin`). The proof alone would
hand off over any extension window docked in REAPER, so this route also REQUIRES the
FX-surface token; within an FX window the proof is what separates the plugin's editor
(OS hand-off — the plugin takes the files as it would from Explorer) from REAPER's
chrome (instrument drop, unchanged). Our own ReaSampler 9000 editor is exempted from
this proof (`WindowCode::Instrument`) — it is FX chrome we own, so a drop on it is the
instrument-drop outcome like any other REAPER chrome, never the OS hand-off.
Once handed off, the law keeps running: on Windows the OS drag loop re-asks it every
turn (`decideOsDragTurn`) and WITHDRAWS the moment it stops answering `OsHandoff`,
giving the gesture back to the panel — or, at a release, committing REAPER's own
outcome at that point. So neither a pass over a plugin's editor nor a return from
outside REAPER costs a REAPER-internal outcome. SWELL's file drag exposes no such hook,
so off Windows the hand-off stays one-way (and there is no plugin-editor route there).
A first-move class lock and a drag-lifetime "cannot hand off" latch both existed here
and were removed — do not reintroduce either.
## Modules
@@ -103,7 +117,8 @@ L7 sub-pass, 2026-07-27):
- `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel.
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through `DropContext::pointerOffHost` — a positive window-ownership proof the shell owes the law (`drag_out_win::pointerOverHostWindow`) — AND only where REAPER's own hit-test named nothing (`Other`, no track), gated ahead of the surface switch, so neither evidence source can give a live drag away to OLE on its own. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
- `window_origin` — whose code implements a window (Host / System / Plugin / Instrument / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. `Instrument` is the one deliberate exemption: our own ReaSampler 9000 module (`WindowFacts::ownModule`, a basename match against this channel's `app_version::vstOutputName() + ".vst3"` — stable and beta each recognise only their own binary) is FX chrome we own, not a hand-off-worthy plugin editor, so it is never `Plugin` and never counts as `isPluginEditorChain` proof — a drag over our own editor stays the instrument-drop outcome. Every other plugin's module is unaffected. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`.
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
+3
View File
@@ -10,6 +10,9 @@ reasampler_test(prune_button LINK prune_button)
reasampler_pure_library(drag_out SOURCES drag_out.cpp)
reasampler_test(drag_out LINK drag_out)
reasampler_pure_library(window_origin SOURCES window_origin.cpp)
reasampler_test(window_origin LINK window_origin)
reasampler_pure_library(theme SOURCES theme.cpp)
reasampler_test(theme LINK theme)
+21 -8
View File
@@ -24,17 +24,24 @@ DropClass decideDropClass(int px, int py, const PanelClientRect& client,
if (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
if (insideClient(px, py, client)) return DropClass::Internal;
// The ONE route to OsHandoff, ahead of the surface switch, and it needs BOTH halves. The
// shell's ownership proof alone is not sufficient: REAPER runs a bridged plugin's UI in a
// separate process, so a window failing that test can still be REAPER's own UI. A recognised
// token therefore VETOES the hand-off instead of producing one — a veto can only move the
// answer toward staying inside REAPER, so pointerOffHost remains the only member whose true
// value can reach OLE. An off-host point REAPER DID name falls through to that surface's own
// outcome below: over a bridged FX window that is the instrument drop, as it is for a native
// one.
// The two routes to OsHandoff, both ahead of the surface switch, each needing BOTH halves.
//
// Leaving REAPER. The process proof alone is not sufficient: REAPER runs a bridged plugin's UI
// in a separate process, so a window failing that test can still be inside REAPER. A
// recognised token therefore VETOES this route instead of producing it — a veto can only move
// the answer toward staying inside REAPER. An off-host point REAPER DID name falls through to
// the second route, then to that surface's own outcome below.
if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack)
return DropClass::OsHandoff;
// A plugin's own editor, with the reverse pairing: REAPER's naming is REQUIRED here, not a
// veto. The code proof alone would hand off over any extension window docked in REAPER; the
// FX-surface token confines it to FX windows, and the proof picks the plugin's editor out of
// that window's REAPER chrome, which keeps its instrument outcome below. A bridged editor
// lands here too — the token that vetoed the first route admits this one.
if (ctx.pointerOverPluginEditor && ctx.surface == ReaperSurface::FxSurface)
return DropClass::OsHandoff;
switch (ctx.surface) {
case ReaperSurface::TrackPanel:
case ReaperSurface::FxSurface:
@@ -100,4 +107,10 @@ OsHandoff decideOsHandoff(const std::vector<std::string>& paths) {
return out;
}
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff) {
if (escapePressed) return OsDragTurn::Cancel;
if (buttonDown) return lawHandsOff ? OsDragTurn::Continue : OsDragTurn::Withdraw;
return lawHandsOff ? OsDragTurn::Drop : OsDragTurn::WithdrawAtRelease;
}
} // namespace reasampler::ui
+38 -12
View File
@@ -4,9 +4,10 @@
// hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
// instrument_drop_win / arrange_drop_win / drag_out_win).
//
// THE LAW: the class is resolved from what is under the cursor on EVERY move; every transition
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is
// reserved for leaving REAPER (DropContext::pointerOffHost owns what proves that) and every
// THE LAW: the class is resolved from what is under the cursor on EVERY move — and on every turn
// of the OS drag loop once handed off (decideOsDragTurn), so every transition is reversible until
// release wherever the OS loop can be re-asked; the OS hand-off is reserved for targets that are
// not REAPER's own UI (DropContext's two proof members own what establishes that) and every
// REAPER-internal target executes natively on release. Do not reintroduce a first-move class
// lock or a drag-lifetime "blocked" latch.
//
@@ -55,7 +56,7 @@ enum class DropClass {
InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
ArrangeInsert, // the timeline — place items at the pointer's track and time
Refuse, // a REAPER surface with no defined outcome for this payload — cue it
OsHandoff, // the pointer left REAPER — hand the file list to the OS
OsHandoff, // not REAPER's own UI (off-host, or a plugin's editor) — hand the files to the OS
};
// Everything the law reads. Nothing here is remembered between evaluations: two evaluations at
@@ -65,15 +66,22 @@ struct DropContext {
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
ReaperSurface surface = ReaperSurface::Other; // only read outside the client rect
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack*
// THE ONE HOME for the hand-off's evidence rule. This is the only member whose true value
// can reach the irreversible OS hand-off, and it is a POSITIVE proof the shell owes the law
// — a window-ownership test (drag_out_win::pointerOverHostWindow), never "the hit-test named
// nothing I recognise", which is equally true of the transport, the toolbar and the docker
// chrome; reading an unnamed token as an exit is what handed live drags to OLE mid-gesture.
// Defaults false so an unset context stays inside REAPER: the failure mode of forgetting to
// resolve it must be a refusal, never a drag given away to OLE. NECESSARY but not sufficient
// — decideDropClass's gate carries the second half and why it needs one.
// THE ONE HOME for the hand-off's evidence rule. This member and pointerOverPluginEditor are
// the only two whose true value can reach the modal OS hand-off, and each is a POSITIVE proof
// the shell owes the law — a window test, never "the hit-test named nothing I recognise",
// which is equally true of the transport, the toolbar and the docker chrome; reading an
// unnamed token as an exit is what handed live drags to OLE mid-gesture. Both default false
// so an unset context stays inside REAPER: forgetting to resolve one must be a refusal, never
// a drag given away to OLE. Each is NECESSARY but not sufficient — decideDropClass pairs each
// with a hit-test condition, and says why.
//
// Process ownership (drag_out_win::pointerOverHostWindow): the window under the pointer is not
// the host process's.
bool pointerOffHost = false;
// Code ownership (drag_out_win::pointerOverPluginEditor, deciding via ui::window_origin): the
// window under the pointer, or one of its ancestors, is implemented by plugin code — a plugin's
// own editor, which takes files the way it takes them from Explorer.
bool pointerOverPluginEditor = false;
};
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
@@ -131,4 +139,22 @@ struct OsHandoff {
// means the internal drag stays live rather than dying half-torn-down.
OsHandoff decideOsHandoff(const std::vector<std::string>& paths);
// --- The OS drag loop, once handed off ---------------------------------------------
// One turn of the OS drag loop. The hand-off does NOT end the law: the loop re-asks it at the
// current pointer every turn, and the moment it stops answering OsHandoff the OS drag is
// WITHDRAWN and the gesture given back to the panel — which is what keeps a pass over a plugin's
// editor, or a return from outside REAPER, from costing the user every REAPER-internal outcome.
enum class OsDragTurn {
Continue, // still over an OS target, button held
Drop, // released over an OS target: the OS delivers the files
Cancel, // Escape: the whole drag ends, nothing happens (unchanged)
Withdraw, // button held over a REAPER surface: resume the internal drag
WithdrawAtRelease, // released over a REAPER surface: resume, then commit it at that point
};
// Escape outranks everything, so a cancel can never be read as a release anywhere.
// `lawHandsOff` is decideDropClass(...) == OsHandoff at the current pointer; ignored on Escape.
OsDragTurn decideOsDragTurn(bool escapePressed, bool buttonDown, bool lawHandsOff);
} // namespace reasampler::ui
+62
View File
@@ -0,0 +1,62 @@
// window_origin — pure implementation. See window_origin.h.
#include "core/ui/window_origin.h"
#include <algorithm>
namespace reasampler::ui {
namespace {
// Case-folded (ASCII only — Windows' own directory names are ASCII), separator-unified, with the
// "\\?\" long-path prefix GetModuleFileName may report dropped, so the same directory compares
// equal however each API chose to spell it.
std::string normalizePath(const std::string& p) {
std::string s = p.rfind("\\\\?\\", 0) == 0 ? p.substr(4) : p;
for (char& c : s) {
if (c == '/') c = '\\';
else if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
while (!s.empty() && s.back() == '\\') s.pop_back();
return s;
}
// Directory containment, not string prefix: C:\Windows must not claim C:\WindowsApps.
bool isBeneath(const std::string& path, const std::string& dir) {
const std::string p = normalizePath(path);
const std::string d = normalizePath(dir);
return !d.empty() && p.size() > d.size() && p.compare(0, d.size(), d) == 0 &&
p[d.size()] == '\\';
}
} // namespace
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot) {
// Another process's procedure address means nothing in ours; the process boundary is itself
// the proof. Inside an FX window that is the bridged plugin's editor.
if (facts.otherProcess) return WindowCode::Plugin;
if (!facts.procResolved) return WindowCode::Unknown;
if (facts.hostModule) return WindowCode::Host;
if (facts.ownModule) return WindowCode::Instrument;
if (normalizePath(systemRoot).empty()) return WindowCode::Unknown;
return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin;
}
bool isPluginEditorChain(const std::vector<WindowCode>& chain) {
return std::find(chain.begin(), chain.end(), WindowCode::Plugin) != chain.end();
}
bool isOwnModulePath(const std::string& path, const std::string& ownName) {
const std::size_t pos = path.find_last_of("\\/");
const std::string base = pos == std::string::npos ? path : path.substr(pos + 1);
if (base.size() != ownName.size()) return false;
for (std::size_t i = 0; i < base.size(); ++i) {
char a = base[i], b = ownName[i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = static_cast<char>(b - 'A' + 'a');
if (a != b) return false;
}
return true;
}
} // namespace reasampler::ui
+49
View File
@@ -0,0 +1,49 @@
#pragma once
// window_origin — whose code implements a window, and whether the window chain under the pointer
// is a plugin's own editor rather than REAPER's chrome. The shell reads the facts (owning process,
// the module holding the window CLASS's procedure); this module decides, so the rule is provable
// without a DAW. Evidence feeding ui::DropContext::pointerOverPluginEditor.
#include <string>
#include <vector>
namespace reasampler::ui {
enum class WindowCode {
Host, // the host executable itself — REAPER's own chrome and containers
System, // an OS module (standard controls, dialog class): says nothing about who placed it
Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin)
Instrument, // our own ReaSampler 9000 module: FX chrome, not a hand-off-worthy plugin editor
Unknown, // the procedure resolved to no loaded module — no evidence either way
};
// What the shell observed about ONE window. The CLASS procedure, not the instance one, because an
// extension subclassing a REAPER window swaps only the instance procedure — reading that would
// make REAPER's own chrome look like foreign code.
struct WindowFacts {
bool otherProcess = false; // the window belongs to a process other than the host's
bool procResolved = false; // the class procedure lies inside a loaded module
bool hostModule = false; // ...and that module is the host executable
// ...and that module is THIS channel's ReaSampler 9000 .vst3. Rationale: core/ui/CLAUDE.md's
// window_origin entry (the ownModule/Instrument exemption).
bool ownModule = false;
std::string modulePath; // that module's file path, UTF-8
};
// `systemRoot` is the OS directory (GetSystemWindowsDirectory); a module beneath it is System.
// An empty root makes every non-host module Unknown rather than Plugin: without it a standard
// button in REAPER's chrome would be indistinguishable from plugin code.
WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot);
// `chain` runs from the window under the pointer up to and including its top-level root. True iff
// any window in it is Plugin code: a plugin's editor sits beneath REAPER's container, and a
// standard control INSIDE that editor is still under the plugin's own window. Unknown is never
// proof — the hand-off this gates is modal, so it needs positive evidence.
bool isPluginEditorChain(const std::vector<WindowCode>& chain);
// True iff `path`'s file name (the part after the last separator, forward or back) matches
// `ownName` case-insensitively — see WindowFacts::ownModule for why this decides Instrument vs
// Plugin. `path` may be a bundle path (only the final component is compared).
bool isOwnModulePath(const std::string& path, const std::string& ownName);
} // namespace reasampler::ui
+4 -3
View File
@@ -1,8 +1,9 @@
#pragma once
// instrument_drop — pure payload-construction core of drop-and-load: dropping a
// bank capture onto a track's panel or FX surface instantiates ReaSampler 9000 on
// that track already playing that capture. Also the classifier that names those
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
// bank capture onto a track's panel or an FX window's REAPER chrome instantiates
// ReaSampler 9000 on that track already playing that capture (a plugin's own editor
// inside an FX window takes the files instead — ui::decideDropClass). Also the
// classifier that names those surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
// (+ the pure sample_map it reuses and the SDK-free UID macros in
// reasampler_uid.h); unit-tested outside the DAW.
//
+2 -2
View File
@@ -57,8 +57,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; `src/shell/bank_ops/CLAUDE.md` owns the one-op-one-Ctrl-Z rule those verbs enforce. 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` — 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.**
- `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`. Owns the two window tests behind `ui::DropContext`'s hand-off proofs (`core/ui/CLAUDE.md` owns the rule they feed). `pointerOverHostWindow` is 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. `pointerOverPluginEditor` walks from the hit window to its root reading each window's owning process and the module of its CLASS procedure (`GetClassLongPtr(GCLP_WNDPROC)` → `GetModuleHandleEx(FROM_ADDRESS)`), and lets `ui::window_origin` decide; its failure modes are listed at the declaration, and it is always false under SWELL. It also sets `WindowFacts::ownModule` — a basename match of the resolved module against this channel's own `reasampler_9000[_beta].vst3` (`app_version::vstOutputName() + ".vst3"`) — so our own instrument's editor classifies as `Instrument`, not `Plugin`, and a drag over it stays the instrument-drop outcome instead of handing off to the OS; every other plugin's editor is unaffected. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin means the extension writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer (the plugin's own reaction to that path list is outside the extension's control).
- `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 or over a plugin's own editor (so an FX surface reaches the instrument drop only as its chrome; a plugin's editor inside it hands off to the OS) — 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.**
- `insert_fx_action` — the "Insert as FX" verb: a second ENTRY POINT to `instrument_drop_win`'s drop body (the bank panel's Placement button and a bindable action), loading the panel's focused capture onto the first selected track. Owns no insert logic of its own — it resolves the selection, folds `core/ui/insert_fx_enable`, and calls `performInstrumentDrop` (which keeps the one undo block). Refuses visibly on every failing precondition; the panel button additionally paints dead for the two panel-local ones.
- `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.
+151 -15
View File
@@ -12,6 +12,10 @@
#include <shlobj.h> // DROPFILES, CF_HDROP
#include <cstring>
#include "core/ui/drag_out.h" // decideOsDragTurn — the OS loop's per-turn verdict
#include "core/ui/window_origin.h" // classifyWindowCode / isPluginEditorChain
#include "core/version/app_version.h" // vstOutputName — this channel's own .vst3 basename
namespace reasampler {
namespace {
@@ -86,10 +90,16 @@ bool ensureOleForThisThread() {
return state > 0;
}
// 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.
// IDropSource: the textbook button/Escape source, plus the law re-asked every turn so the drag can
// be withdrawn back to the panel (ui::decideOsDragTurn). Always requests the default cursors.
// [verify — DAW] QueryContinueDrag's cadence: MSDN words it as called on a keyboard or
// mouse-BUTTON state change, so on plain moves it may not run; if so a mid-drag withdrawal waits
// for the next state change, but the release — a button change — is still decided here.
class DropSource final : public IDropSource {
public:
explicit DropSource(bool (*lawHandsOff)()) : lawHandsOff_(lawHandsOff) {}
ui::OsDragTurn withdrawal() const { return withdrawal_; }
// IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override {
if (riid == IID_IUnknown || riid == IID_IDropSource) {
@@ -108,15 +118,33 @@ public:
}
// 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
const bool escape = escapePressed != FALSE;
// Escape ignores the law, so it is not probed then; no callback is the textbook source.
const bool lawHandsOff = escape || !lawHandsOff_ || lawHandsOff_();
const ui::OsDragTurn turn =
ui::decideOsDragTurn(escape, (keyState & MK_LBUTTON) != 0, lawHandsOff);
switch (turn) {
case ui::OsDragTurn::Continue:
return S_OK;
case ui::OsDragTurn::Drop:
return DRAGDROP_S_DROP;
case ui::OsDragTurn::Cancel:
return DRAGDROP_S_CANCEL;
case ui::OsDragTurn::Withdraw:
case ui::OsDragTurn::WithdrawAtRelease:
withdrawal_ = turn; // the target sees a cancel (DragLeave), never a drop
return DRAGDROP_S_CANCEL;
}
return DRAGDROP_S_CANCEL;
}
HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override {
return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor
}
private:
volatile LONG refs_ = 1;
bool (*lawHandsOff_)() = nullptr;
// Continue doubles as "not withdrawn": it is never the turn that ends the loop.
ui::OsDragTurn withdrawal_ = ui::OsDragTurn::Continue;
};
// Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is
@@ -214,25 +242,34 @@ private:
} // namespace
bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty()) return false;
if (!ensureOleForThisThread()) return false;
OsDragEnd initiateDragOut(HWND__* /*panelHwnd*/, const std::vector<std::string>& absolutePaths,
bool (*lawHandsOff)()) {
if (absolutePaths.empty()) return OsDragEnd::Concluded;
if (!ensureOleForThisThread()) return OsDragEnd::Concluded;
HGLOBAL hdrop = buildHDrop(absolutePaths);
if (!hdrop) return false;
if (!hdrop) return OsDragEnd::Concluded;
auto* data = new HDropDataObject(hdrop); // takes ownership of hdrop
auto* source = new DropSource();
auto* source = new DropSource(lawHandsOff);
DWORD effect = 0;
// COPY-ONLY: the allowed-effects mask is DROPEFFECT_COPY alone. MOVE is never
// offered, so no drop target can relocate (delete) the bank file.
const HRESULT hr = DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
DoDragDrop(data, source, DROPEFFECT_COPY, &effect);
const ui::OsDragTurn withdrawal = source->withdrawal();
source->Release();
data->Release(); // frees the source HGLOBAL via HDropDataObject's dtor
return hr == DRAGDROP_S_DROP && effect == DROPEFFECT_COPY;
switch (withdrawal) {
case ui::OsDragTurn::Withdraw: return OsDragEnd::Withdrawn;
case ui::OsDragTurn::WithdrawAtRelease: return OsDragEnd::WithdrawnAtRelease;
case ui::OsDragTurn::Continue:
case ui::OsDragTurn::Drop:
case ui::OsDragTurn::Cancel: break;
}
return OsDragEnd::Concluded;
}
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
@@ -265,6 +302,98 @@ bool pointerOverHostWindow(int screenX, int screenY) {
return pid == GetCurrentProcessId();
}
namespace {
std::string toUtf8(const std::wstring& w) {
if (w.empty()) return {};
const int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), nullptr,
0, nullptr, nullptr);
if (n <= 0) return {};
std::string s(static_cast<std::size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), &s[0], n, nullptr,
nullptr);
return s;
}
std::string moduleFileName(HMODULE mod) {
std::wstring buf(MAX_PATH, L'\0');
for (;;) {
const DWORD n = GetModuleFileNameW(mod, &buf[0], static_cast<DWORD>(buf.size()));
if (n == 0) return {};
if (n < buf.size()) { buf.resize(n); return toUtf8(buf); }
if (buf.size() >= 32768) return {}; // truncated at the long-path limit: no evidence
buf.resize(buf.size() * 2);
}
}
// This channel's own ReaSampler 9000 module file name (e.g. "reasampler_9000.vst3", or the
// "_beta" variant) — the same name CMake gives the built .vst3 (OUTPUT_NAME + SUFFIX). Matching
// logic lives in core/ui/window_origin's isOwnModulePath; see there.
const std::string& ownVstFileName() {
static const std::string name = version::vstOutputName() + ".vst3";
return name;
}
// Empty on failure, which window_origin reads as "cannot tell OS code from plugin code".
const std::string& systemRoot() {
static const std::string root = [] {
wchar_t dir[MAX_PATH] = {0};
const UINT n = GetSystemWindowsDirectoryW(dir, MAX_PATH);
return (n > 0 && n < MAX_PATH) ? toUtf8(std::wstring(dir, n)) : std::string{};
}();
return root;
}
ui::WindowFacts windowFacts(HWND hwnd) {
ui::WindowFacts f;
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != GetCurrentProcessId()) {
f.otherProcess = true;
return f;
}
// The charset-matched read returns the procedure's address; a mismatched one can return a
// handle that lies in no module, which is left as unresolved rather than guessed at.
const ULONG_PTR proc = IsWindowUnicode(hwnd) ? GetClassLongPtrW(hwnd, GCLP_WNDPROC)
: GetClassLongPtrA(hwnd, GCLP_WNDPROC);
HMODULE mod = nullptr;
if (!proc || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(proc), &mod) ||
!mod) {
return f;
}
f.procResolved = true;
f.hostModule = mod == GetModuleHandleW(nullptr);
// classifyWindowCode returns Host on hostModule before ever reading modulePath — skip the
// (string-building) lookup in that case.
if (!f.hostModule) {
f.modulePath = moduleFileName(mod);
f.ownModule = ui::isOwnModulePath(f.modulePath, ownVstFileName());
}
return f;
}
} // namespace
bool pointerOverPluginEditor(int screenX, int screenY) {
// The CLASS procedure's module rather than the window's hInstance: a bare-SDK plugin may
// create its editor with the hInstance of the parent REAPER hands it, so hInstance would
// read that plugin as REAPER.
const POINT pt{screenX, screenY};
const HWND hit = WindowFromPoint(pt);
if (!hit) return false;
const HWND root = GetAncestor(hit, GA_ROOT);
constexpr std::size_t kMaxDepth = 64; // a guard against a malformed tree, not a real bound
std::vector<ui::WindowCode> chain;
for (HWND h = hit; h && chain.size() < kMaxDepth; h = GetAncestor(h, GA_PARENT)) {
chain.push_back(ui::classifyWindowCode(windowFacts(h), systemRoot()));
if (h == root) break;
}
return ui::isPluginEditorChain(chain);
}
} // namespace reasampler
#else // ---- macOS / Linux (SWELL) -----------------------------------------------------
@@ -278,8 +407,9 @@ namespace reasampler {
// SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given
// window. Unlike OLE it exposes no per-source effect mask, so the copy-only
// guarantee here rests on SWELL's copy semantics rather than an explicit mask.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths) {
if (absolutePaths.empty() || !panelHwnd) return false;
OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths,
bool (* /*lawHandsOff*/)()) {
if (absolutePaths.empty() || !panelHwnd) return OsDragEnd::Concluded;
std::vector<const char*> ptrs;
ptrs.reserve(absolutePaths.size());
@@ -288,7 +418,8 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
// 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)
// Fire-and-forget; SWELL owns the drag from here, with no per-turn hook to re-ask the law.
return OsDragEnd::Concluded;
}
// SWELL exposes no readiness probe ahead of SWELL_InitiateDragDropOfFileList (which itself
@@ -307,6 +438,11 @@ bool pointerOverHostWindow(int screenX, int screenY) {
return WindowFromPoint(pt) != nullptr;
}
// No plugin-editor hand-off off Windows: every FX window keeps its instrument outcome, as before.
bool pointerOverPluginEditor(int /*screenX*/, int /*screenY*/) {
return false;
}
} // namespace reasampler
#endif
+48 -4
View File
@@ -1,6 +1,7 @@
#pragma once
// 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
// crossed it (left the host process, or reached a plugin's own editor), 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.*.
@@ -21,11 +22,22 @@ struct HWND__; // avoid dragging windows.h into every includer; the shell casts
namespace reasampler {
// How the OS drag ended, as far as the caller must act on it. Whether a Concluded drag was
// accepted is not reported — nothing acts on it.
enum class OsDragEnd {
Concluded, // dropped, cancelled, or failed to start: the gesture is over
Withdrawn, // ui::OsDragTurn::Withdraw — the button is still down
WithdrawnAtRelease, // ui::OsDragTurn::WithdrawAtRelease — the button came up over REAPER
};
// `absolutePaths` must already be resolved/existing/de-duped (drag_out::assemblePathList
// output). No-op when empty. BLOCKING on Windows: OLE DoDragDrop runs its own modal
// message loop until drop/cancel. Returns true iff the drop was accepted
// (DROPEFFECT_COPY); the return is advisory — a failed drag surfaces no error.
bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths);
// message loop, re-asking `lawHandsOff` (the gesture law at the current pointer) on every
// IDropSource::QueryContinueDrag and withdrawing per ui::decideOsDragTurn. macOS/Linux: SWELL's
// file drag is fire-and-forget with no such hook, so it always reports Concluded — the hand-off
// stays irreversible there.
OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolutePaths,
bool (*lawHandsOff)());
// Side-effect-free readiness probe for initiateDragOut. Call BEFORE tearing down internal drag
// state — an OS that isn't ready (OLE unavailable, HDROP build failure) must not consume the
@@ -44,4 +56,36 @@ bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
// Side-effect-free; safe on every mouse-move.
bool pointerOverHostWindow(int screenX, int screenY);
// True iff plugin code implements the window under the screen point or one of its ancestors —
// the POSITIVE proof behind ui::DropContext::pointerOverPluginEditor. Windows reads, per window
// from the hit window up to its root, the owning process and the module holding the window
// CLASS's procedure, and lets ui::window_origin decide. The failure modes, all toward REAPER's
// own outcome (the instrument drop) rather than toward OLE:
// - a plugin that draws straight into the parent REAPER hands it, creating no window of its
// own, leaves nothing of its code in the chain;
// - a plugin binary installed beneath the Windows directory reads as an OS module;
// - a class procedure that resolves to no module (a charset-mismatch handle) is no evidence;
// - an all-#32770 dialog-based editor (the generic Windows dialog class) resolves to no
// recognizable plugin module — falls to REAPER's outcome;
// - a .NET/WPF editor's class procedure is a CLR-thunked address with no resolvable module —
// also falls to REAPER's outcome; `[verify]`;
// - our own ReaSampler 9000 module (this channel's reasampler_9000[_beta].vst3, matched by
// basename) also falls to REAPER's outcome, deliberately — see ui::WindowFacts::ownModule;
// - ReaSampler 9000 itself run bridged/in a separate process reads as Plugin, same as any other
// bridged plugin — banner (the OS hand-off fires over our own editor), harmless;
// - a renamed ReaSampler 9000 binary (the basename no longer matches ownVstFileName()) misses
// the exemption and reads as Plugin — banner, harmless.
// The opposite direction is unguarded: a third-party hook that replaces REAPER's own chrome
// class procedure via SetClassLongPtr(GCLP_WNDPROC) would make REAPER's own window read as
// Plugin — toward OLE instead of away from it.
// [verify — DAW] that REAPER's GetThingFromPoint names an fx_* token over the plugin's own child
// window — the law requires it, so an unnamed editor would refuse rather than hand off.
// [verify — DAW] that OLE never walks up to an ancestor drop target (e.g. REAPER's own file
// import) when the plugin editor's window registers no IDropTarget of its own.
// [verify — DAW] that GetThingFromPoint cannot name a REAPER region (e.g. an fx_* token) when a
// foreign window overlaps it at the same screen point — this fn trusts WindowFromPoint's hit
// over whatever GetThingFromPoint separately reports.
// SWELL: always false (no plugin-editor hand-off off Windows). Side-effect-free.
bool pointerOverPluginEditor(int screenX, int screenY);
} // namespace reasampler
+32 -5
View File
@@ -13,6 +13,7 @@
#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only)
#include "shell/instrument/reasampler_processor.h"
#include "core/version/app_version.h" // vstOutputName — channel-forked window class name
using namespace Steinberg;
@@ -20,7 +21,32 @@ namespace reasampler::vst {
#ifdef _WIN32
namespace {
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// Channel-forked so stable and beta never fight over one class registration (a second
// RegisterClassW against an already-taken name silently fails, leaving that channel's editors
// running the FIRST-registered channel's wndProc). vstOutputName() is ASCII (CMake OUTPUT_NAME),
// so a plain widen is safe.
const std::wstring& childClassName() {
static const std::wstring name = [] {
const std::string& base = version::vstOutputName();
return std::wstring(base.begin(), base.end()) + L"VstEditor";
}();
return name;
}
// The module holding a given code address — used to resolve THIS wndProc's own module rather
// than the parent's hInstance REAPER hands us. Registering against the parent would put the class
// in REAPER's own module while the wndproc still points into our DLL — a dangling procedure
// pointer once our DLL unloads but REAPER's class registration outlives it. Takes the address
// rather than naming wndProc directly so a free function in this anonymous namespace doesn't need
// access to the private member.
HMODULE moduleHandleFor(void* codeAddress) {
HMODULE mod = nullptr;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(codeAddress), &mod);
return mod;
}
// The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes within a bounded
@@ -105,8 +131,9 @@ void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
// Own module, not the parent's hInstance — see moduleHandleFor() above.
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
moduleHandleFor(reinterpret_cast<void*>(&ReaSamplerEditor::wndProc)));
if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false;
@@ -114,7 +141,7 @@ void ReaSamplerEditor::attachedToParent() {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.lpszClassName = childClassName().c_str();
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
// CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second
// WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's
@@ -134,7 +161,7 @@ void ReaSamplerEditor::attachedToParent() {
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
childHwnd_ = CreateWindowExW(0, childClassName().c_str(), L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
+4 -3
View File
@@ -2,9 +2,9 @@
## Scope
The REAPER-facing shell for the docked bank panel: the eight `bank_panel` split TUs
The REAPER-facing shell for the docked bank panel: the nine `bank_panel` split TUs
(`panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` /
`panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via
`panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via
`panel_state.h`), plus `draw_kit`, the shared LICE draw shell also consumed by the
VST3 editor (`shell/instrument/`).
@@ -47,12 +47,13 @@ live in `shell/bank_ops`, a sibling directory, not here.
## Modules
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into nine TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_drag_os` the OS hand-off/resume seam split out of `panel_drag`; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route.
- `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read).
- `panel_render` — the WM_PAINT draw.
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
- `panel_drag` — the hover + card-drag state machine + drop dispatch.
- `panel_drag_os` — the OS hand-off/resume seam: the live re-ask the OS drag loop uses, the hand-off itself, and resuming the internal drag when the OS loop gives the gesture back.
- `panel_thumbnails` — the PCM→envelope thumbnail cache + the bank-change fingerprint pass.
- `panel_audition` — the preview-playback engine.
- `panel_bank_ops` — the menu/prompt UX skin over the promptless `shell/bank_ops` verbs.
+27 -47
View File
@@ -148,11 +148,13 @@ void applyDragCursor(CardGesture g) {
SetCursor(LoadCursor(nullptr, idc));
}
} // namespace
// The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The
// cue is set on EVERY move, so "will this work" is visible before the button comes up, and a
// refusal is a cursor the user can see rather than a release that does nothing. Cursor-only by
// design — the docked panel is usually not under the pointer during an out-of-client drag, so
// panel status text would be invisible exactly when it is needed.
// panel status text would be invisible exactly when it is needed. Shared with panel_drag_os.cpp.
void applyDropCue(DropCue cue) {
const char* idc = nullptr;
switch (cue) {
@@ -169,15 +171,7 @@ void applyDropCue(DropCue cue) {
SetCursor(LoadCursor(nullptr, idc));
}
// One independent evaluation of the drag's target: the resolved class plus the live facts its
// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is
// per-move and stateless").
struct LiveDrop {
DropClass cls = DropClass::None;
MediaTrack* track = nullptr;
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
};
// Shared with panel_drag_os.cpp (lawHandsOffAtCursor).
LiveDrop resolveLiveDrop(int x, int y) {
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
@@ -199,10 +193,11 @@ 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.
// Separate probes because they answer independent questions: what REAPER named, whether
// we are still inside REAPER at all, and whether plugin code owns the window. The law's
// hand-off gates pair them — see ui::DropContext::pointerOffHost.
ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y);
ctx.pointerOverPluginEditor = pointerOverPluginEditor(sp.x, sp.y);
out.track = probe.track;
out.screenX = sp.x;
}
@@ -219,39 +214,7 @@ LiveDrop resolveLiveDrop(int x, int y) {
return out;
}
// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal
// 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 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. 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
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
// coupling to the OS-readiness failures the pure decision cannot see.
const std::vector<std::string> paths = resolveDragPathsForOs();
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
applyDropCue(DropCue::Refuse);
invalidatePanel();
return;
}
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
resetDragState();
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));
}
namespace {
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,
// mirroring handleClick's precedence exactly (so the element that lights on hover is
@@ -339,7 +302,24 @@ void maybeShowTooltip() {
}
}
void onMouseMove(int x, int y) {
void onMouseMove(int x, int y, WPARAM wParam) {
// A move with the button already up while still dragging means the release was missed (the
// OS hand-off's own modal loop can eat a WM_LBUTTONUP, and coalescing can lose one too) —
// treat it as the release now rather than leaving dragging stuck for the next plain click.
#ifdef _WIN32
if (g_panel.dragging && !(wParam & MK_LBUTTON)) {
onLBtnUp(x, y);
return;
}
#else
// SWELL never sets wParam on WM_MOUSEMOVE (swell-types.h ~1315), so the button-up test must
// read the physical key state instead of the message's own (always-zero) wParam.
if (g_panel.dragging && !(GetAsyncKeyState(VK_LBUTTON) & 0x8000)) {
onLBtnUp(x, y);
return;
}
#endif
// Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns
// the visual feedback then — a drop-target highlight, not a hover).
if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y);
+113
View File
@@ -0,0 +1,113 @@
// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp: the live
// re-ask the OS drag loop uses to decide whether to keep the drag, the hand-off itself, and
// resuming the internal drag when the OS loop gives the gesture back.
//
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called directly here;
// REAPER SDK types arrive via panel_state.h.
#include <string>
#include <vector>
#include "shell/panel/panel_state.h"
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam
namespace reasampler::panel {
namespace {
// The physical left button, honouring a swapped primary button (SM_SWAPBUTTON) — distinct from
// dragging/dragArmed, which only say what OUR state machine last believed. Read fresh rather than
// trusted, because DoDragDrop's own modal loop can consume the WM_LBUTTONUP that would otherwise
// have told us the button came up.
bool leftButtonPhysicallyDown() {
const int vk = GetSystemMetrics(SM_SWAPBUTTON) ? VK_RBUTTON : VK_LBUTTON;
return (GetAsyncKeyState(vk) & 0x8000) != 0;
}
// Re-asked by the OS drag loop on every turn. The payload fields the law reads survive
// resetDragState, so it still sees the drag the hand-off wound down. With the panel gone
// mid-loop there is no gesture left to give back, so the OS keeps the drag.
bool lawHandsOffAtCursor() {
if (!g_panel.hwnd) return true;
POINT p{};
GetCursorPos(&p);
ScreenToClient(g_panel.hwnd, &p);
return resolveLiveDrop(p.x, p.y).cls == DropClass::OsHandoff;
}
// The OS loop gave the gesture back (ui::OsDragTurn::Withdraw*): restore exactly the fields
// resetDragState cleared that the drag still needs, then either keep dragging under our own
// capture or — the release was consumed by the OS loop, so no WM_LBUTTONUP will follow — commit
// at the release point now. No move is replayed here: the next WM_MOUSEMOVE re-resolves the cue,
// and replaying one could re-enter handOffToOs from inside itself.
void resumeInternalDrag(const std::string& primaryId, bool released) {
if (!g_panel.hwnd) return;
g_panel.dragArmed = true;
g_panel.dragging = true;
g_panel.dragPrimaryId = primaryId;
POINT p{};
GetCursorPos(&p);
ScreenToClient(g_panel.hwnd, &p);
if (released) {
onLBtnUp(p.x, p.y);
return;
}
// [verify — DAW] that SetCapture re-takes the mouse mid-press after DoDragDrop released it.
SetCapture(g_panel.hwnd);
// The button can have come up between DoDragDrop's own release and this re-capture (the
// Withdrawn turn only promises the button was down AT the last OS-loop turn, not at this
// instant) — without this check dragging stays armed with a stale payload, and the very next
// plain click commits a reorder/replace nobody asked for.
if (!leftButtonPhysicallyDown()) {
onLBtnUp(p.x, p.y);
return;
}
invalidatePanel();
}
} // namespace
// The hand-off: DoDragDrop takes mouse capture and runs its own modal 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. The OS loop may hand the
// gesture back (ui::OsDragTurn owns why); a side effect worth keeping is that, on Windows, a
// return trip can no longer drop a CF_HDROP on REAPER's file import or on this panel's own
// WM_DROPFILES ingest — the release there is withdrawn, not dropped.
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
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
// coupling to the OS-readiness failures the pure decision cannot see.
const std::vector<std::string> paths = resolveDragPathsForOs();
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
applyDropCue(DropCue::Refuse);
invalidatePanel();
return;
}
const std::string primaryId = g_panel.dragPrimaryId; // resetDragState clears it
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
resetDragState();
invalidatePanel();
// COPY-ONLY; blocking on Windows.
switch (initiateDragOut(g_panel.hwnd, paths, &lawHandsOffAtCursor)) {
case OsDragEnd::Withdrawn:
resumeInternalDrag(primaryId, /*released=*/false);
return;
case OsDragEnd::WithdrawnAtRelease:
resumeInternalDrag(primaryId, /*released=*/true);
return;
case OsDragEnd::Concluded:
break;
}
// 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));
}
} // namespace reasampler::panel
+18 -2
View File
@@ -536,11 +536,27 @@ void registerAccel();
void unregisterAccel();
// panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag).
// Per-mouse-move work stays plain free-function calls.
void onMouseMove(int x, int y);
// Per-mouse-move work stays plain free-function calls. wParam carries WM_MOUSEMOVE's own
// button-state flags (MK_LBUTTON etc) so a move with the button already up can be told apart
// from GetAsyncKeyState's live-polled state.
void onMouseMove(int x, int y, WPARAM wParam);
void onLBtnUp(int x, int y);
void handleRightClick(int x, int y);
void resetDragState();
void maybeShowTooltip();
// One independent evaluation of the drag's target: the resolved class plus the live facts its
// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is
// per-move and stateless"). Shared with panel_drag_os.cpp.
struct LiveDrop {
DropClass cls = DropClass::None;
MediaTrack* track = nullptr;
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
};
LiveDrop resolveLiveDrop(int x, int y);
void applyDropCue(DropCue cue);
// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp.
void handOffToOs();
} // namespace reasampler::panel
+1 -1
View File
@@ -152,7 +152,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
return 0;
}
case WM_MOUSEMOVE:
onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam);
return 0;
case WM_LBUTTONUP:
onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
+152 -19
View File
@@ -3,8 +3,9 @@
//
// Covers:
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track} x
// {over a host window, off it}, inside and outside the client rect, plus the half-open edge
// and a non-zero panel origin.
// {over a host window, off it} x {plugin code under the pointer, not}, inside and outside the
// client rect, plus the half-open edge and a non-zero panel origin.
// * The OS drag loop's per-turn verdict (decideOsDragTurn), exhaustively.
// * Reversibility and speed-independence, stated as properties: a class transition sequence
// resolves the same forwards and backwards, and one unresolvable evaluation cannot change
// any later evaluation's outcome.
@@ -50,6 +51,16 @@ static DropContext offHost(ReaperSurface surface = ReaperSurface::Other, bool si
return c;
}
// The same drag with the shell's plugin-code proof set: the window under the pointer is a plugin's
// own editor. Defaults to where it matters — an FX surface REAPER attributed to a track.
static DropContext overPlugin(ReaperSurface surface = ReaperSurface::FxSurface, bool single = true,
bool haveTrack = true, bool offHostToo = false) {
DropContext c = ctx(surface, single, haveTrack);
c.pointerOverPluginEditor = true;
c.pointerOffHost = offHostToo;
return c;
}
// Every surface the law enumerates, so the matrix tests iterate rather than list.
static const ReaperSurface kAllSurfaces[] = {
ReaperSurface::TrackPanel, ReaperSurface::FxSurface, ReaperSurface::FxEmbed,
@@ -78,10 +89,11 @@ static void testInsideClientIsAlwaysInternal() {
CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal);
// Even a stale off-host proof cannot reinterpret a point inside our own client rect:
// the inside-client answer is resolved ahead of the hand-off gate, so re-entering the
// panel always resumes the bank-to-bank gesture.
// Even a stale proof of either kind cannot reinterpret a point inside our own client
// rect: the inside-client answer is resolved ahead of both hand-off gates, so
// re-entering the panel always resumes the bank-to-bank gesture.
CHECK(decideDropClass(200, 150, kPanel, offHost(s, single)) == DropClass::Internal);
CHECK(decideDropClass(200, 150, kPanel, overPlugin(s, single)) == DropClass::Internal);
}
}
}
@@ -119,7 +131,8 @@ static void testSingleOverTrackPanelIsInstrumentDrop() {
DropClass::InstrumentDrop);
}
// The FX chain / floating-FX windows keep their existing outcome.
// The FX chain / floating-FX windows' own chrome — REAPER's preset bar, the chain list — keeps its
// existing outcome: no plugin-code proof, so the instrument drop.
static void testSingleOverFxSurfaceIsInstrumentDrop() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
@@ -198,7 +211,8 @@ static void testNullTrackWithSurfaceRefuses() {
// --- The hand-off gate ---------------------------------------------------------
// THE REGRESSION FLOOR. No reading of REAPER's hit-test can reach the modal OLE loop while the
// pointer is over a host window — asserted over EVERY surface the probe can report, both payload
// pointer is over a host window and no plugin code owns it (ctx() sets neither proof) — asserted
// over EVERY surface the probe can report, both payload
// sizes and both track verdicts, not just the toolbar/transport cell that was reported. The
// defect was structural (an unnamed token read as "the user left REAPER"), so the guarantee has
// to be structural too.
@@ -231,9 +245,10 @@ static void testOffHostHandsOffOnlyWhereReaperNamedNothing() {
}
// The shipped exception the token condition exists for: REAPER runs a bridged plugin's UI in
// reaper_host*.exe, so the window-ownership test reports off-host over a floating bridged FX
// editor even though the pointer never left REAPER. There the recognised token vetoes the
// hand-off and the surface's own REAPER-internal outcome stands — identical to the native case.
// reaper_host*.exe, so the process test reports off-host over a bridged FX window even though the
// pointer never left REAPER. The recognised token vetoes the PROCESS route, so on its own that
// proof changes nothing over a named surface — whether the bridged editor then hands off is the
// plugin-code route's call alone (testBridgedEditorHandsOffThroughThePluginRoute).
static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) ==
DropClass::InstrumentDrop);
@@ -264,13 +279,71 @@ static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
}
}
// --- The plugin-editor route ---------------------------------------------------
// THE FEATURE: over a plugin's own editor inside an FX window, the files go to the plugin through
// the OS — whatever the payload size and whether or not REAPER attributed a track, since the
// plugin, not a track, is the receiver.
static void testPluginEditorOverFxSurfaceHandsOff() {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, single, haveTrack)) ==
DropClass::OsHandoff);
}
}
}
// The same point WITHOUT the proof is the FX window's chrome: the instrument drop for a single
// card, the multi refusal otherwise. The proof is the whole difference, in both directions.
static void testSameFxSurfaceWithoutTheProofKeepsItsOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface)) ==
DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface, false)) ==
DropClass::OsHandoff);
}
// Plugin code under the pointer anywhere REAPER did not name an FX surface — the TCP, the MCP, the
// embed strip, the arrange, an extension window docked in REAPER (Other) — changes nothing: the
// token confines the route to FX windows. Asserted over every such cell, on-host and off.
static void testPluginProofOffAnFxSurfaceChangesNothing() {
for (ReaperSurface s : kAllSurfaces) {
if (s == ReaperSurface::FxSurface) continue;
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) {
const DropContext without =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(s, single, haveTrack, off)) ==
decideDropClass(kOutX, kOutY, kPanel, without));
}
}
}
}
}
// A bridged editor: the process proof AND the plugin proof are both set, REAPER names the FX
// window. The token vetoed the process route; the plugin route still hands off.
static void testBridgedEditorHandsOffThroughThePluginRoute() {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, true, haveTrack,
/*offHostToo=*/true)) == DropClass::OsHandoff);
}
}
// --- Not-a-drag ----------------------------------------------------------------
// No armed samples, or not dragging -> None regardless of position or surface.
static void testNoDragOrNoSamplesIsNone() {
for (ReaperSurface s : kAllSurfaces) {
for (bool off : {false, true}) {
DropContext c = off ? offHost(s) : ctx(s);
for (int proof = 0; proof < 3; ++proof) { // none, off-host, plugin editor
DropContext c = proof == 1 ? offHost(s) : proof == 2 ? overPlugin(s) : ctx(s);
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false};
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
@@ -299,12 +372,22 @@ static void testClassTransitionsAreReversible() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// The hand-off leg is irreversible only because the SHELL goes modal on it. The law itself
// stays stateless across it: evaluating an off-host context leaves the next in-REAPER
// evaluation exactly where it was.
// The hand-off legs stay stateless too: evaluating an off-host or plugin-editor context leaves
// the next in-REAPER evaluation exactly where it was — which is what lets the OS loop withdraw
// on the law's own answer (decideOsDragTurn) rather than on anything remembered.
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// Through a plugin's editor and back out over its own window's chrome: the instrument drop is
// still there after the pass, and so is everything after it.
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
}
// One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable
@@ -331,10 +414,11 @@ static void testDragSpeedCannotChangeTheOutcome() {
const DropContext destination = ctx(ReaperSurface::TrackPanel);
const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination);
// The slow path crosses everything else first, off-host legs included.
// The slow path crosses everything else first, off-host and plugin-editor legs included.
for (ReaperSurface s : kAllSurfaces) {
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, offHost(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, overPlugin(s));
(void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client
}
CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick);
@@ -350,9 +434,11 @@ static void testNoLiveOutsideCombinationResolvesToNone() {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) {
const DropClass c = decideDropClass(
kOutX, kOutY, kPanel,
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack));
for (bool plugin : {false, true}) {
DropContext c0 =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
c0.pointerOverPluginEditor = plugin;
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, c0);
CHECK(c != DropClass::None);
CHECK(c != DropClass::Internal);
}
@@ -360,6 +446,7 @@ static void testNoLiveOutsideCombinationResolvesToNone() {
}
}
}
}
// Every class carries a cue, and only the two the shell deliberately does not draw map to a
// no-cursor cue — so "will this work" is answerable before release for every resolvable class.
@@ -490,6 +577,44 @@ static void testAllSkippedSelectionKeepsInternalDrag() {
CHECK(!h.handOffToOs);
}
// --- The OS drag loop, once handed off ---------------------------------------------
// The whole truth table, one cell per line so a regression names its cell.
static void testOsDragTurnTruthTable() {
// Button held: stay with the OS while the law still hands off, withdraw the moment it doesn't.
CHECK(decideOsDragTurn(false, true, true) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, false) == OsDragTurn::Withdraw);
// Released: the OS drops only where the law hands off; over a REAPER surface the release is
// given back to the panel, so REAPER's own outcome runs instead of a CF_HDROP import.
CHECK(decideOsDragTurn(false, false, true) == OsDragTurn::Drop);
CHECK(decideOsDragTurn(false, false, false) == OsDragTurn::WithdrawAtRelease);
// Escape ends the drag outright in every cell — never read as a release, never a withdrawal
// that would resume a drag the user just cancelled.
CHECK(decideOsDragTurn(true, true, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, true, false) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, false) == OsDragTurn::Cancel);
}
// End to end with the law: a hand-off started over a plugin's editor, then the pointer moves onto
// that FX window's chrome with the button held — withdrawn, and the resumed drag's release there is
// the instrument drop, not an OS drop. Moving back onto the editor keeps the OS drag.
static void testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces() {
auto handsOff = [](const DropContext& c) {
return decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::OsHandoff;
};
CHECK(decideOsDragTurn(false, true, handsOff(overPlugin())) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, handsOff(ctx(ReaperSurface::FxSurface))) ==
OsDragTurn::Withdraw);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideOsDragTurn(false, false, handsOff(ctx(ReaperSurface::Arrange))) ==
OsDragTurn::WithdrawAtRelease);
CHECK(decideOsDragTurn(false, false, handsOff(overPlugin())) == OsDragTurn::Drop);
// Leaving REAPER entirely is handed off by the other route and held the same way.
CHECK(decideOsDragTurn(false, true, handsOff(offHost())) == OsDragTurn::Continue);
}
int main() {
testInsideClientIsAlwaysInternal();
testBoundaryHalfOpen();
@@ -513,6 +638,11 @@ int main() {
testOffHostOverANamedSurfaceKeepsTheReaperOutcome();
testNoDragOrNoSamplesIsNone();
testPluginEditorOverFxSurfaceHandsOff();
testSameFxSurfaceWithoutTheProofKeepsItsOutcome();
testPluginProofOffAnFxSurfaceChangesNothing();
testBridgedEditorHandsOffThroughThePluginRoute();
testClassTransitionsAreReversible();
testUnresolvableEvaluationDoesNotAffectTheNext();
testDragSpeedCannotChangeTheOutcome();
@@ -532,6 +662,9 @@ int main() {
testResolvablePayloadHandsOff();
testAllSkippedSelectionKeepsInternalDrag();
testOsDragTurnTruthTable();
testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+237
View File
@@ -0,0 +1,237 @@
// Standalone tests for reasampler::ui::window_origin — no REAPER, no Win32, no test framework.
// Asserts the per-window code fold and the chain rule the plugin-editor hand-off proof rests on.
#include "../src/core/ui/window_origin.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static const std::string kWinDir = "C:\\Windows";
static WindowFacts inModule(const std::string& path, bool host = false) {
WindowFacts f;
f.procResolved = true;
f.hostModule = host;
f.modulePath = path;
return f;
}
static WindowFacts ownModule(const std::string& path) {
WindowFacts f;
f.procResolved = true;
f.modulePath = path;
f.ownModule = true;
return f;
}
// --- classifyWindowCode --------------------------------------------------------
// REAPER's own chrome and containers: the class procedure lives in the executable.
static void testHostExecutableIsHost() {
CHECK(classifyWindowCode(inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true),
kWinDir) == WindowCode::Host);
}
// Standard controls and the dialog class are implemented by OS modules, wherever the OS keeps
// them — System32, SysWOW64 for a 32-bit host, and the side-by-side store comctl32 v6 loads from.
static void testOsModulesAreSystem() {
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("C:\\Windows\\SysWOW64\\user32.dll"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(
inModule("C:\\Windows\\WinSxS\\amd64_microsoft.windows.common-controls_6595b64144ccf1df"
"_6.0.26100.1_none_x\\comctl32.dll"),
kWinDir) == WindowCode::System);
}
// The same directory spelled differently by two APIs still compares equal: case, separator
// direction, a trailing separator on the root, and the long-path prefix.
static void testSystemMatchIgnoresSpelling() {
CHECK(classifyWindowCode(inModule("c:/WINDOWS/system32/USER32.DLL"), kWinDir) ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "C:\\WINDOWS\\") ==
WindowCode::System);
CHECK(classifyWindowCode(inModule("\\\\?\\C:\\Windows\\System32\\user32.dll"), kWinDir) ==
WindowCode::System);
}
// Containment, not string prefix: a sibling directory whose name merely starts with the OS
// directory's is plugin territory, and so is the OS directory itself named as a file path.
static void testSiblingDirectoryIsNotSystem() {
CHECK(classifyWindowCode(inModule("C:\\WindowsApps\\thing.dll"), kWinDir) ==
WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("C:\\Windows2\\thing.dll"), kWinDir) == WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("C:\\Windows"), kWinDir) == WindowCode::Plugin);
}
// A plugin binary anywhere outside the OS directory — the VST3 bundle, a VST2 folder, a helper
// DLL a plugin loads beside itself — is Plugin.
static void testPluginBinariesArePlugin() {
CHECK(classifyWindowCode(
inModule("C:\\Program Files\\Common Files\\VST3\\Serum2.vst3\\Contents\\x86_64-win\\"
"Serum2.vst3"),
kWinDir) == WindowCode::Plugin);
CHECK(classifyWindowCode(inModule("D:\\VstPlugins\\Qt5Widgets.dll"), kWinDir) ==
WindowCode::Plugin);
}
// Another process's window is plugin code by the process boundary alone, whatever its other facts
// claim: a procedure address from another process resolved in ours is meaningless.
static void testOtherProcessIsPluginRegardlessOfModule() {
WindowFacts f = inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true);
f.otherProcess = true;
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Plugin);
WindowFacts g;
g.otherProcess = true; // nothing resolved at all
CHECK(classifyWindowCode(g, kWinDir) == WindowCode::Plugin);
}
// No module (a charset-mismatch handle, generated code) is no evidence — never Plugin.
static void testUnresolvedProcIsUnknown() {
WindowFacts f;
f.modulePath = "C:\\VST\\x.dll"; // ignored without a resolved procedure
CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Unknown);
}
// Without the OS directory a standard control cannot be told from plugin code, so every non-host
// module degrades to Unknown — the failure must not read REAPER's buttons as a plugin.
static void testMissingSystemRootNeverYieldsPlugin() {
CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "") ==
WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "") == WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "\\") == WindowCode::Unknown);
CHECK(classifyWindowCode(inModule("C:\\REAPER\\reaper.exe", true), "") == WindowCode::Host);
}
// Our own ReaSampler 9000 module — stable or beta, whichever the shell set ownModule for — is
// Instrument, not Plugin: FX chrome we own, so a drag over it must not hand off to the OS.
static void testOwnModuleIsInstrument() {
CHECK(classifyWindowCode(
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3"), kWinDir) ==
WindowCode::Instrument);
CHECK(classifyWindowCode(
ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000_beta.vst3"),
kWinDir) == WindowCode::Instrument);
}
// --- isPluginEditorChain -------------------------------------------------------
// Pointer on REAPER's FX-window chrome: a standard control or a REAPER container, under the
// dialog-class root. No plugin code anywhere in the chain.
static void testChromeChainIsNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::System, WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Host}));
}
// Pointer on the plugin's editor window itself.
static void testPluginWindowChainIsPlugin() {
CHECK(isPluginEditorChain({WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
}
// A standard control INSIDE the plugin's editor (a native text box) is System on its own; the
// plugin's window above it is what makes the point the plugin's.
static void testStandardControlInsidePluginIsPlugin() {
CHECK(isPluginEditorChain(
{WindowCode::System, WindowCode::Plugin, WindowCode::Host, WindowCode::System}));
}
// A bridged plugin's editor embedded across the process boundary: the hit window is Plugin by
// process, REAPER's container and root above it are in-process.
static void testBridgedEmbeddedEditorIsPlugin() {
WindowFacts bridged;
bridged.otherProcess = true;
CHECK(isPluginEditorChain({classifyWindowCode(bridged, kWinDir), WindowCode::Host,
WindowCode::System}));
}
// A pointer over our own instrument's editor — Instrument code, not Plugin — is not hand-off
// proof: it stays REAPER's FX chrome, unlike a genuine plugin editor (testPluginWindowChainIsPlugin).
static void testOwnInstrumentChainIsNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
}
// Another plugin's editor sitting alongside our own instrument in the same chain is still proof —
// only OUR module is exempted, nobody else's.
static void testOtherPluginBesideOwnInstrumentIsStillPlugin() {
CHECK(isPluginEditorChain(
{WindowCode::Plugin, WindowCode::Instrument, WindowCode::Host, WindowCode::System}));
}
// Unknown anywhere, and nothing else, is not proof; an empty chain (no window at all) is not.
static void testUnknownAndEmptyChainsAreNotPlugin() {
CHECK(!isPluginEditorChain({WindowCode::Unknown, WindowCode::Host, WindowCode::System}));
CHECK(!isPluginEditorChain({WindowCode::Unknown}));
CHECK(!isPluginEditorChain({}));
}
// --- isOwnModulePath -----------------------------------------------------------
static void testOwnModulePathCaseFolds() {
CHECK(isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000.vst3"));
CHECK(isOwnModulePath("C:\\VST3\\ReaSampler_9000.VST3", "reasampler_9000.vst3"));
}
static void testOwnModulePathBundlePathForwardAndBackSlash() {
CHECK(isOwnModulePath(
"C:\\VST3\\reasampler_9000.vst3\\Contents\\x86_64-win\\reasampler_9000.vst3",
"reasampler_9000.vst3"));
CHECK(isOwnModulePath(
"C:/VST3/reasampler_9000.vst3/Contents/x86_64-win/reasampler_9000.vst3",
"reasampler_9000.vst3"));
}
static void testOwnModulePathStableAndBetaDoNotMatchEachOther() {
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000_beta.vst3", "reasampler_9000.vst3"));
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000_beta.vst3"));
}
static void testOwnModulePathBareFilenameWithNoSeparatorMatches() {
CHECK(isOwnModulePath("reasampler_9000.vst3", "reasampler_9000.vst3"));
}
static void testOwnModulePathEmptyPathDoesNotMatch() {
CHECK(!isOwnModulePath("", "reasampler_9000.vst3"));
}
static void testOwnModulePathTrailingSeparatorDoesNotMatch() {
CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3\\", "reasampler_9000.vst3"));
}
int main() {
testHostExecutableIsHost();
testOsModulesAreSystem();
testSystemMatchIgnoresSpelling();
testSiblingDirectoryIsNotSystem();
testPluginBinariesArePlugin();
testOtherProcessIsPluginRegardlessOfModule();
testUnresolvedProcIsUnknown();
testMissingSystemRootNeverYieldsPlugin();
testOwnModuleIsInstrument();
testChromeChainIsNotPlugin();
testPluginWindowChainIsPlugin();
testStandardControlInsidePluginIsPlugin();
testBridgedEmbeddedEditorIsPlugin();
testOwnInstrumentChainIsNotPlugin();
testOtherPluginBesideOwnInstrumentIsStillPlugin();
testUnknownAndEmptyChainsAreNotPlugin();
testOwnModulePathCaseFolds();
testOwnModulePathBundlePathForwardAndBackSlash();
testOwnModulePathStableAndBetaDoNotMatchEachOther();
testOwnModulePathBareFilenameWithNoSeparatorMatches();
testOwnModulePathEmptyPathDoesNotMatch();
testOwnModulePathTrailingSeparatorDoesNotMatch();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}