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:
+26
-11
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user