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

This commit is contained in:
2026-08-03 16:45:14 -04:00
14 changed files with 271 additions and 110 deletions
+9 -3
View File
@@ -88,8 +88,14 @@ L7 sub-pass, 2026-07-27):
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. A first-move class lock and a drag-lifetime "cannot hand off" latch both
existed here and were removed — do not reintroduce either.
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.
## Modules
@@ -97,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 (OffReaper / TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. **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).
+11 -3
View File
@@ -24,10 +24,18 @@ 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;
switch (ctx.surface) {
case ReaperSurface::OffReaper:
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:
case ReaperSurface::FxSurface:
// One instance holds one capture, so a multi payload names no instrument to build —
+17 -7
View File
@@ -6,8 +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, 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
@@ -32,16 +33,16 @@ struct DragState {
};
// What REAPER reports under the pointer, reduced to the surfaces the law distinguishes.
// Produced from GetThingFromPoint's info token by wire::classifyReaperSurface.
// 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 {
OffReaper, // not over REAPER at all — the one irreversible exit
TrackPanel, // TCP/MCP, ANY sub-element: the WHOLE panel is the instrument hotspot
FxSurface, // fx_* — the FX chain and floating-FX windows
FxEmbed, // tcp.fxembed / mcp.fxembed — an instance already draws there
Arrange, // the timeline
Other, // ruler, transport, spacers, docker chrome, unknown future tokens
Other, // ruler, transport, spacers, docker chrome, unknown/empty future tokens
Count, // sentinel, NOT a real surface — tests/test_drag_out.cpp's kAllSurfaces is
// pinned against this via static_assert so a 7th surface can't silently skip
// pinned against this via static_assert so a 6th surface can't silently skip
// the exhaustiveness matrix
};
@@ -62,8 +63,17 @@ enum class DropClass {
struct DropContext {
DragState drag;
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
ReaperSurface surface = ReaperSurface::OffReaper; // only read outside the client rect
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.
bool pointerOffHost = false;
};
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
+1 -1
View File
@@ -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, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. 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
+1 -2
View File
@@ -87,11 +87,10 @@ DropOutcome decideDropOutcome(const DropAttempt& attempt) {
return out;
}
ui::ReaperSurface classifyReaperSurface(const std::string& info, bool haveTrack) {
ui::ReaperSurface classifyReaperSurface(const std::string& info) {
// See the header contract for the prefix rule and the ordering it depends on.
auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; };
if (info.empty()) return haveTrack ? ui::ReaperSurface::Other : ui::ReaperSurface::OffReaper;
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return ui::ReaperSurface::FxEmbed;
if (startsWith("fx_")) return ui::ReaperSurface::FxSurface;
if (startsWith("tcp") || startsWith("mcp")) return ui::ReaperSurface::TrackPanel;
+7 -7
View File
@@ -55,9 +55,9 @@ std::vector<std::uint8_t> buildVstPresetBytes(const std::string& classIdHex32,
// preset. Deterministic.
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId);
// Pure classifier for GetThingFromPoint's (info string, track-was-returned) pair
// into the surfaces the drag-out gesture law distinguishes. The SDK warns future
// versions may append information, so every rule is PREFIX-based:
// Pure classifier for GetThingFromPoint's info string into the surfaces the drag-out
// gesture law distinguishes. The SDK warns future versions may append information, so
// every rule is PREFIX-based:
// "tcp.fxembed*" / "mcp.fxembed*" -> FxEmbed. Checked FIRST: it is the surface
// an existing instance already draws on, and a drop must not stack a second.
// "fx_*" -> FxSurface (FX chain + floating FX windows).
@@ -66,12 +66,12 @@ std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId)
// still means "sampler on this track", and the old glyph-only rule is exactly
// why such a drop landed nowhere.
// "arrange*" -> Arrange.
// "" with no track -> OffReaper (the pointer left REAPER).
// anything else, incl. "" WITH a track -> Other. Over REAPER on a surface we
// cannot name: refuse visibly, never guess an outcome.
// 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 — 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, bool haveTrack);
ui::ReaperSurface classifyReaperSurface(const std::string& info);
// The raw component-state bytes the preset carries, exposed so the round-trip
// test can decode them back through sample_map::deserializeComponentState and