diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index d07fb5e..1a801c0 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -62,7 +62,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. diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index bfc5a22..6c2c720 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -86,16 +86,28 @@ 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). + + 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 +115,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 / 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. 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). diff --git a/src/core/ui/CMakeLists.txt b/src/core/ui/CMakeLists.txt index 78a7860..f099cf7 100644 --- a/src/core/ui/CMakeLists.txt +++ b/src/core/ui/CMakeLists.txt @@ -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) diff --git a/src/core/ui/drag_out.cpp b/src/core/ui/drag_out.cpp index 9917eeb..934f307 100644 --- a/src/core/ui/drag_out.cpp +++ b/src/core/ui/drag_out.cpp @@ -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& 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 diff --git a/src/core/ui/drag_out.h b/src/core/ui/drag_out.h index 96d8b9e..961dabe 100644 --- a/src/core/ui/drag_out.h +++ b/src/core/ui/drag_out.h @@ -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& 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 diff --git a/src/core/ui/window_origin.cpp b/src/core/ui/window_origin.cpp new file mode 100644 index 0000000..6248ad8 --- /dev/null +++ b/src/core/ui/window_origin.cpp @@ -0,0 +1,48 @@ +// window_origin — pure implementation. See window_origin.h. + +#include "core/ui/window_origin.h" + +#include + +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(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 (normalizePath(systemRoot).empty()) return WindowCode::Unknown; + return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin; +} + +bool isPluginEditorChain(const std::vector& chain) { + return std::find(chain.begin(), chain.end(), WindowCode::Plugin) != chain.end(); +} + +} // namespace reasampler::ui diff --git a/src/core/ui/window_origin.h b/src/core/ui/window_origin.h new file mode 100644 index 0000000..cc10c07 --- /dev/null +++ b/src/core/ui/window_origin.h @@ -0,0 +1,40 @@ +#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 +#include + +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) + 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 + 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& chain); + +} // namespace reasampler::ui diff --git a/src/core/wire/instrument_drop.h b/src/core/wire/instrument_drop.h index 720e779..bfcf6fc 100644 --- a/src/core/wire/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -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. // diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index 3e1e13e..561f479 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -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. 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 writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer. +- `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. diff --git a/src/shell/actions/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp index cb2d171..49a4fe3 100644 --- a/src/shell/actions/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -12,6 +12,9 @@ #include // DROPFILES, CF_HDROP #include +#include "core/ui/drag_out.h" // decideOsDragTurn — the OS loop's per-turn verdict +#include "core/ui/window_origin.h" // classifyWindowCode / isPluginEditorChain + namespace reasampler { namespace { @@ -86,10 +89,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 +117,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 +241,34 @@ private: } // namespace -bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& absolutePaths) { - if (absolutePaths.empty()) return false; - if (!ensureOleForThisThread()) return false; +OsDragEnd initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& 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& absolutePaths) { @@ -265,6 +301,85 @@ 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(w.size()), nullptr, + 0, nullptr, nullptr); + if (n <= 0) return {}; + std::string s(static_cast(n), '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(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(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); + } +} + +// 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(proc), &mod) || + !mod) { + return f; + } + f.procResolved = true; + f.hostModule = mod == GetModuleHandleW(nullptr); + f.modulePath = moduleFileName(mod); + 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 (ReaSampler 9000's own + // editor does), 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 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 +393,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& absolutePaths) { - if (absolutePaths.empty() || !panelHwnd) return false; +OsDragEnd initiateDragOut(HWND__* panelHwnd, const std::vector& absolutePaths, + bool (* /*lawHandsOff*/)()) { + if (absolutePaths.empty() || !panelHwnd) return OsDragEnd::Concluded; std::vector ptrs; ptrs.reserve(absolutePaths.size()); @@ -288,7 +404,8 @@ bool initiateDragOut(HWND__* panelHwnd, const std::vector& absolute // srcrect null: SWELL positions the drag image at the current event. No custom icon. SWELL_InitiateDragDropOfFileList(reinterpret_cast(panelHwnd), nullptr, ptrs.data(), static_cast(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 +424,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 diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index f13a9b9..7129ac7 100644 --- a/src/shell/actions/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -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& 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& 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,18 @@ bool canInitiateDragOut(const std::vector& 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. +// [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. +// SWELL: always false (no plugin-editor hand-off off Windows). Side-effect-free. +bool pointerOverPluginEditor(int screenX, int screenY); + } // namespace reasampler diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 001143b..a5bb4fe 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -199,10 +199,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,16 +220,46 @@ 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. +// 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); + invalidatePanel(); +} + +// 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 @@ -242,11 +273,22 @@ void handOffToOs() { return; } + const std::string primaryId = g_panel.dragPrimaryId; // resetDragState clears it if (GetCapture() == g_panel.hwnd) ReleaseCapture(); resetDragState(); invalidatePanel(); - initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows + // 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. diff --git a/tests/test_drag_out.cpp b/tests/test_drag_out.cpp index 6ebb259..af4338e 100644 --- a/tests/test_drag_out.cpp +++ b/tests/test_drag_out.cpp @@ -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,11 +434,14 @@ 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)); - CHECK(c != DropClass::None); - CHECK(c != DropClass::Internal); + 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); + } } } } @@ -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; } diff --git a/tests/test_window_origin.cpp b/tests/test_window_origin.cpp new file mode 100644 index 0000000..4290313 --- /dev/null +++ b/tests/test_window_origin.cpp @@ -0,0 +1,162 @@ +// 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 +#include +#include + +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; +} + +// --- 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); +} + +// --- 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})); +} + +// 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({})); +} + +int main() { + testHostExecutableIsHost(); + testOsModulesAreSystem(); + testSystemMatchIgnoresSpelling(); + testSiblingDirectoryIsNotSystem(); + testPluginBinariesArePlugin(); + testOtherProcessIsPluginRegardlessOfModule(); + testUnresolvedProcIsUnknown(); + testMissingSystemRootNeverYieldsPlugin(); + + testChromeChainIsNotPlugin(); + testPluginWindowChainIsPlugin(); + testStandardControlInsidePluginIsPlugin(); + testBridgedEmbeddedEditorIsPlugin(); + testUnknownAndEmptyChainsAreNotPlugin(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}