Close the bridged-process hole in the drag-out hand-off gate
OsHandoff now needs the window-ownership proof AND a hit-test that named nothing, so a bridged plugin's UI can't read as off-REAPER.
This commit is contained in:
@@ -90,8 +90,12 @@ L7 sub-pass, 2026-07-27):
|
||||
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. A first-move class lock and a drag-lifetime "cannot hand off" latch both
|
||||
existed here and were removed — do not reintroduce either.
|
||||
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.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -99,7 +103,7 @@ 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`), gated ahead of the surface switch — so no reading of REAPER's hit-test can give a live drag away to OLE. **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 `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.
|
||||
- `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).
|
||||
|
||||
@@ -24,10 +24,16 @@ 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 hand-off gate sits AHEAD of the surface switch and is the only route to OsHandoff, so
|
||||
// no reading of REAPER's hit-test — recognised, unrecognised, or empty — can reach the modal
|
||||
// OLE loop while the pointer is still over a host window.
|
||||
if (ctx.pointerOffHost) return DropClass::OsHandoff;
|
||||
// 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.
|
||||
if (ctx.pointerOffHost && ctx.surface == ReaperSurface::Other && !ctx.haveTrack)
|
||||
return DropClass::OsHandoff;
|
||||
|
||||
switch (ctx.surface) {
|
||||
case ReaperSurface::TrackPanel:
|
||||
|
||||
+13
-14
@@ -6,10 +6,9 @@
|
||||
//
|
||||
// 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 — which the shell must PROVE (DropContext::pointerOffHost), never
|
||||
// infer from a hit-test token it could not name — and every REAPER-internal target executes
|
||||
// natively on release. Do not reintroduce a first-move class lock or a drag-lifetime "blocked"
|
||||
// latch.
|
||||
// reserved for leaving REAPER (DropContext::pointerOffHost owns what proves that) and every
|
||||
// REAPER-internal target executes natively on release. Do not reintroduce a first-move class
|
||||
// lock or a drag-lifetime "blocked" latch.
|
||||
//
|
||||
// Path-list assembly: turns armed sample ids into the absolute path list an OS drop carries
|
||||
// (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id
|
||||
@@ -34,10 +33,8 @@ struct DragState {
|
||||
};
|
||||
|
||||
// What REAPER reports under the pointer, reduced to the surfaces the law distinguishes.
|
||||
// Produced from GetThingFromPoint's info token by wire::classifyReaperSurface. There is
|
||||
// deliberately NO off-REAPER member: this vocabulary describes what REAPER's own hit-test
|
||||
// named, and "the pointer left REAPER" is a window-ownership fact no token can carry —
|
||||
// it enters through DropContext::pointerOffHost instead.
|
||||
// Produced from GetThingFromPoint's info token by wire::classifyReaperSurface. No off-REAPER
|
||||
// member: that fact enters through DropContext::pointerOffHost, which owns the reasoning.
|
||||
enum class ReaperSurface {
|
||||
TrackPanel, // TCP/MCP, ANY sub-element: the WHOLE panel is the instrument hotspot
|
||||
FxSurface, // fx_* — the FX chain and floating-FX windows
|
||||
@@ -68,12 +65,14 @@ 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 gate on the irreversible OS hand-off, and the only member whose true value can
|
||||
// produce it. It is a POSITIVE proof the shell owes the law — a window-ownership test
|
||||
// (drag_out_win::pointerOverHostWindow), not "the hit-test named nothing I recognise",
|
||||
// which is equally true of the transport, the toolbar and the docker chrome. 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.
|
||||
// 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.
|
||||
bool pointerOffHost = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ This directory owns two cross-artifact contracts specifically:
|
||||
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
|
||||
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
|
||||
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer was READ BACK from its own key; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot. The read-back verdict behind `BakeWriteProof` is `ext_state_read.h`'s `extStateWriteLanded`, above. `bakeLandingAfterPersist` is the ONE route to a `Banked` landing: every `Land` verdict is assigned `Unpersisted` and passed through it, so no shell path — a dedup hit least of all, since its target may be an entry the same pass just added — can claim the word without the pass's persist having reported success. What `Banked` claims is exactly that `persisted` input — a saved project was active and the bank write was issued — and the enum comment is that claim's one home.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` info token onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which claims the WHOLE track panel rather than just its FX sub-elements. An empty token is `Other`, never an off-REAPER verdict — the SDK documents no off-REAPER return, so the toolbar/transport/docker chrome all report nothing REAPER names. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` info token onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which claims the WHOLE track panel rather than just its FX sub-elements. An empty token is `Other`, never an off-REAPER verdict (`core/ui/CLAUDE.md` owns that rule). All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -67,11 +67,8 @@ std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId)
|
||||
// why such a drop landed nowhere.
|
||||
// "arrange*" -> Arrange.
|
||||
// anything else, "" INCLUDED -> Other. A surface we cannot name: refuse
|
||||
// visibly, never guess an outcome. An empty token is NOT evidence the pointer
|
||||
// left REAPER — the SDK documents no off-REAPER return at all, and the transport,
|
||||
// toolbar, menu bar and docker chrome all report nothing REAPER names. Reading it
|
||||
// as "off REAPER" is what handed live drags to OLE mid-gesture; whether the
|
||||
// pointer left is a window-ownership fact, resolved in the shell.
|
||||
// visibly, never guess an outcome. An empty token is NOT evidence the pointer left
|
||||
// REAPER — see ui::DropContext::pointerOffHost for why that rule exists.
|
||||
// The exact live token is DAW-only — confirm via
|
||||
// reaper.GetThingFromPoint(reaper.GetMousePosition()) in ReaScript if unsure.
|
||||
ui::ReaperSurface classifyReaperSurface(const std::string& info);
|
||||
|
||||
@@ -41,8 +41,8 @@ is owned by other directories and only skinned here.
|
||||
|
||||
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
|
||||
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
|
||||
- `drag_out_win` — 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` — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only.
|
||||
- `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, which is `drag_out_win`'s — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Also owns `pointerOverHostWindow`, the window-ownership test behind `ui::DropContext::pointerOffHost` (`core/ui/CLAUDE.md` owns the hand-off rule it feeds) — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only. Its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER.
|
||||
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
||||
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
|
||||
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
||||
|
||||
@@ -247,14 +247,18 @@ bool canInitiateDragOut(const std::vector<std::string>& absolutePaths) {
|
||||
bool pointerOverHostWindow(int screenX, int screenY) {
|
||||
// Process identity, not window identity: REAPER's arrange, its floating FX and VST editor
|
||||
// windows, its menus and its docker chrome are separate top-level HWNDs with no common
|
||||
// ancestor, so no GetAncestor/GetParent walk reaches them all — but every one of them is
|
||||
// created by the process this extension is loaded into.
|
||||
// ancestor, so no GetAncestor/GetParent walk reaches them all — but they are windows of the
|
||||
// process this extension is loaded into, bar the bridged-plugin case the header names.
|
||||
// Mouse capture does not affect WindowFromPoint: it hit-tests the desktop window tree, not
|
||||
// input routing. It does skip hidden and DISABLED windows, so a REAPER window disabled
|
||||
// behind a modal dialog could fall through to whatever sits beneath — no such dialog can be
|
||||
// up while our panel holds capture for a drag.
|
||||
// input routing. It does skip hidden and DISABLED windows, so a REAPER window disabled behind
|
||||
// a modal dialog raised mid-drag (a REAPER timer or another extension can raise one — capture
|
||||
// is not a modality lock) would fall through to whatever sits beneath and read as off-host.
|
||||
// Accepted, unverified: not reproduced, and nothing here was checked against REAPER's own
|
||||
// dialog behavior.
|
||||
const POINT pt{screenX, screenY};
|
||||
const HWND hwnd = WindowFromPoint(pt);
|
||||
// Not an unknown resolved toward hand-off: a null return is the desktop tree stating there is
|
||||
// no window at that point at all, which is a determinate "no host window here".
|
||||
if (!hwnd) return false;
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
|
||||
@@ -33,11 +33,14 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector<std::string>& absolute
|
||||
// probe HGLOBAL immediately; SWELL exposes no probe, so macOS/Linux reduces to the non-empty check.
|
||||
bool canInitiateDragOut(const std::vector<std::string>& absolutePaths);
|
||||
|
||||
// True iff a window belonging to the host (REAPER) process sits under the screen point —
|
||||
// the POSITIVE proof ui::DropContext::pointerOffHost is the negation of. Every REAPER
|
||||
// surface qualifies whether or not GetThingFromPoint can name it: the arrange, a TCP, a
|
||||
// floating FX or VST editor window, the transport, the toolbar, a menu, the docker chrome
|
||||
// and our own panel are all windows of this process, because the extension runs inside it.
|
||||
// True iff a window belonging to the host (REAPER) process sits under the screen point — the
|
||||
// POSITIVE proof ui::DropContext::pointerOffHost is the negation of (that member owns the
|
||||
// evidence rule). Nearly every REAPER surface qualifies whether or not GetThingFromPoint can
|
||||
// name it, because the extension runs inside that process.
|
||||
// The exception, and it ships: a plugin set to "Run as separate/dedicated process" draws its
|
||||
// UI out of reaper_host32/64.exe, and WindowFromPoint descends to the deepest child — so this
|
||||
// reports false over a bridged plugin's editor although the pointer never left REAPER. The
|
||||
// law's hand-off gate absorbs that by also requiring REAPER's own hit-test to name nothing.
|
||||
// Side-effect-free; safe on every mouse-move.
|
||||
bool pointerOverHostWindow(int screenX, int screenY);
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ struct DropProbe {
|
||||
// Wraps GetThingFromPoint: its track comes back as-is, its info string goes to the pure
|
||||
// wire::classifyReaperSurface. Cheap enough to run on every mouse-move, but the panel
|
||||
// evaluates it only OUTSIDE its own client rect — the internal drag never pays for it.
|
||||
// It reports only what REAPER named; whether the pointer is still inside REAPER at all is
|
||||
// drag_out_win::pointerOverHostWindow's, and no surface reported here implies an exit.
|
||||
// Reports only what REAPER named; no output here implies an exit (ui::DropContext::
|
||||
// pointerOffHost owns that rule).
|
||||
DropProbe probeDropTarget(int screenX, int screenY);
|
||||
|
||||
// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its
|
||||
|
||||
@@ -199,11 +199,9 @@ LiveDrop resolveLiveDrop(int x, int y) {
|
||||
const DropProbe probe = probeDropTarget(sp.x, sp.y);
|
||||
ctx.surface = probe.surface;
|
||||
ctx.haveTrack = probe.track != nullptr;
|
||||
// Two independent probes because they answer two independent questions: REAPER's
|
||||
// hit-test names the surface, window ownership decides whether we are still in REAPER
|
||||
// at all. The SDK hit-test cannot answer the second — GetThingFromPoint documents no
|
||||
// off-REAPER return, so its silence over the transport or the toolbar used to read as
|
||||
// "the user left" and gave the live drag away to OLE.
|
||||
// Two probes because they answer two independent questions: what REAPER named, and
|
||||
// whether we are still inside REAPER at all. The law's hand-off gate reads both — see
|
||||
// ui::DropContext::pointerOffHost.
|
||||
ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y);
|
||||
out.track = probe.track;
|
||||
out.screenX = sp.x;
|
||||
@@ -249,6 +247,10 @@ void handOffToOs() {
|
||||
invalidatePanel();
|
||||
|
||||
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
|
||||
|
||||
// The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes
|
||||
// onLBtnUp's not-dragging branch and never restores the arrow — do it here instead.
|
||||
SetCursor(LoadCursor(nullptr, IDC_ARROW));
|
||||
}
|
||||
|
||||
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,
|
||||
|
||||
+47
-8
@@ -41,7 +41,8 @@ static DropContext ctx(ReaperSurface surface, bool single = true, bool haveTrack
|
||||
}
|
||||
|
||||
// The same drag with the shell's positive off-REAPER proof set. `surface` is whatever the SDK
|
||||
// hit-test last reported; the point of the gate is that it no longer matters.
|
||||
// hit-test reported at that point — it still matters: the hand-off needs REAPER to have named
|
||||
// nothing too, so the defaults here (Other, no track) are the only combination that hands off.
|
||||
static DropContext offHost(ReaperSurface surface = ReaperSurface::Other, bool single = true,
|
||||
bool haveTrack = false) {
|
||||
DropContext c = ctx(surface, single, haveTrack);
|
||||
@@ -212,20 +213,57 @@ static void testNoInReaperCombinationCanHandOff() {
|
||||
}
|
||||
}
|
||||
|
||||
// The converse: once the shell has PROVEN the pointer left, the hand-off does not depend on what
|
||||
// the last hit-test happened to say or on whether a track was resolved — the gate sits ahead of
|
||||
// the surface switch, so a stale surface reading cannot suppress a genuine exit.
|
||||
static void testOffHostHandsOffWhateverTheSurfaceSaid() {
|
||||
// The converse, stated as the gate's exact shape: with the ownership proof set, the hand-off
|
||||
// happens EXACTLY on the cells where REAPER's hit-test also named nothing — no surface, no track.
|
||||
// Both directions in one loop, so neither half can be weakened without a failure: a gate that
|
||||
// dropped the token condition fails on the named cells, one that over-tightened fails on Other.
|
||||
static void testOffHostHandsOffOnlyWhereReaperNamedNothing() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(s, single, haveTrack)) ==
|
||||
DropClass::OsHandoff);
|
||||
const bool namedNothing = (s == ReaperSurface::Other) && !haveTrack;
|
||||
const DropClass c =
|
||||
decideDropClass(kOutX, kOutY, kPanel, offHost(s, single, haveTrack));
|
||||
CHECK((c == DropClass::OsHandoff) == namedNothing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::TrackPanel, true, true)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::Arrange, true, true)) ==
|
||||
DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxEmbed, true, true)) ==
|
||||
DropClass::Refuse);
|
||||
|
||||
// And not just for those four spot values: over every named cell the ownership proof changes
|
||||
// nothing at all, which is what "a veto can only move the answer toward staying in REAPER"
|
||||
// means operationally.
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
if (s == ReaperSurface::Other) continue; // the one surface the gate can hand off from
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(s, single, haveTrack)) ==
|
||||
decideDropClass(kOutX, kOutY, kPanel, ctx(s, single, haveTrack)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Same equality on the remaining named cell: Other WITH a track is a surface REAPER did
|
||||
// attribute, so it refuses off-host exactly as it does on-host.
|
||||
for (bool single : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
offHost(ReaperSurface::Other, single, true)) == DropClass::Refuse);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Not-a-drag ----------------------------------------------------------------
|
||||
|
||||
// No armed samples, or not dragging -> None regardless of position or surface.
|
||||
@@ -471,7 +509,8 @@ int main() {
|
||||
|
||||
testNullTrackWithSurfaceRefuses();
|
||||
testNoInReaperCombinationCanHandOff();
|
||||
testOffHostHandsOffWhateverTheSurfaceSaid();
|
||||
testOffHostHandsOffOnlyWhereReaperNamedNothing();
|
||||
testOffHostOverANamedSurfaceKeepsTheReaperOutcome();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
|
||||
testClassTransitionsAreReversible();
|
||||
|
||||
@@ -248,20 +248,15 @@ static void testUnnamedReaperSurfacesAreOther() {
|
||||
CHECK(surfaceFor("something_reaper_adds_in_2030") == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// An empty info string is Other, NOT an off-REAPER verdict. GetThingFromPoint documents no
|
||||
// off-REAPER return at all, so its silence over the transport, the toolbar or the docker chrome
|
||||
// says only "nothing I name" — reading it as "the user left REAPER" is what handed live drags to
|
||||
// OLE mid-gesture. Whether the pointer left REAPER is a window-ownership fact the shell proves
|
||||
// separately (drag_out_win::pointerOverHostWindow) and feeds to the law as
|
||||
// DropContext::pointerOffHost; no classifier output can produce a hand-off on its own.
|
||||
// An empty info string is Other, NOT an off-REAPER verdict — ui::DropContext::pointerOffHost
|
||||
// owns why.
|
||||
static void testEmptyInfoIsOtherNotOffReaper() {
|
||||
CHECK(surfaceFor("") == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// There is deliberately no second empty-info case: track presence stopped being an input to the
|
||||
// classifier when the off-REAPER verdict left the vocabulary. "No classifier output can trigger a
|
||||
// hand-off" is now structural (no such member exists; test_drag_out's static_assert pins the
|
||||
// member count) rather than something a per-token loop could falsify.
|
||||
// There is deliberately no second empty-info case: track presence is not an input to the
|
||||
// classifier, and "no classifier output alone can trigger a hand-off" is structural (no such
|
||||
// member exists) rather than something a per-token loop could falsify.
|
||||
|
||||
// Do not reintroduce a per-surface "capture carries" loop test: buildInstrumentDropPreset takes
|
||||
// only sampleId (proven by testPresetRoundTripsThroughInstrumentReader), and per-surface
|
||||
|
||||
Reference in New Issue
Block a user