diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index d07fb5e..a295cce 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -27,6 +27,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp + ${REASAMPLER_SRC_DIR}/shell/panel/panel_drag_os.cpp ${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp ${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp @@ -62,7 +63,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out window_origin instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) # NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The # instrument renders its own bake in its own process, which is what keeps the extension's # link graph free of the voice engine — a link edge to it here means the design drifted. diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index bfc5a22..97274db 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -86,16 +86,30 @@ L7 sub-pass, 2026-07-27): - **The drag-out law is per-move and stateless.** The class is resolved from the current pointer on every move and again at the release point; nothing is latched between evaluations. That is what makes every transition reversible and what - makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving - REAPER entirely is the one irreversible transition, because the OS hand-off goes - modal — so it must be PROVEN (a window-ownership test in the shell), never inferred - from a hit-test token REAPER could not name; an unrecognised surface *inside* REAPER - refuses. That proof is necessary but NOT sufficient: a plugin bridged into - `reaper_host*.exe` fails the process test while still being REAPER's own UI, so the - hand-off additionally requires REAPER's hit-test to have named nothing — a recognised - token can only ever veto the hand-off, never produce one. A first-move class lock and - a drag-lifetime "cannot hand off" latch both existed here and were removed — do not - reintroduce either. + makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. The OS + hand-off goes modal, so it must be PROVEN by a window test in the shell, never + inferred from a hit-test token REAPER could not name; an unrecognised surface + *inside* REAPER refuses. There are exactly two proofs, each necessary but NOT + sufficient, each paired with a hit-test condition: + - **Leaving REAPER** (process ownership). A plugin bridged into `reaper_host*.exe` + fails the process test while still being inside REAPER, so this route also requires + REAPER's hit-test to have named nothing — here a recognised token can only VETO. + - **A plugin's own editor** (code ownership, `window_origin`). The proof alone would + hand off over any extension window docked in REAPER, so this route also REQUIRES the + FX-surface token; within an FX window the proof is what separates the plugin's editor + (OS hand-off — the plugin takes the files as it would from Explorer) from REAPER's + chrome (instrument drop, unchanged). Our own ReaSampler 9000 editor is exempted from + this proof (`WindowCode::Instrument`) — it is FX chrome we own, so a drop on it is the + instrument-drop outcome like any other REAPER chrome, never the OS hand-off. + + Once handed off, the law keeps running: on Windows the OS drag loop re-asks it every + turn (`decideOsDragTurn`) and WITHDRAWS the moment it stops answering `OsHandoff`, + giving the gesture back to the panel — or, at a release, committing REAPER's own + outcome at that point. So neither a pass over a plugin's editor nor a return from + outside REAPER costs a REAPER-internal outcome. SWELL's file drag exposes no such hook, + so off Windows the hand-off stays one-way (and there is no plugin-editor route there). + A first-move class lock and a drag-lifetime "cannot hand off" latch both existed here + and were removed — do not reintroduce either. ## Modules @@ -103,7 +117,8 @@ L7 sub-pass, 2026-07-27): - `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel. - `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip. - `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button. -- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through `DropContext::pointerOffHost` — a positive window-ownership proof the shell owes the law (`drag_out_win::pointerOverHostWindow`) — AND only where REAPER's own hit-test named nothing (`Other`, no track), gated ahead of the surface switch, so neither evidence source can give a live drag away to OLE on its own. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site. +- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it; there is deliberately no off-REAPER member, since no token can carry that fact), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. `OsHandoff` is reachable ONLY through the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site. +- `window_origin` — whose code implements a window (Host / System / Plugin / Instrument / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. `Instrument` is the one deliberate exemption: our own ReaSampler 9000 module (`WindowFacts::ownModule`, a basename match against this channel's `app_version::vstOutputName() + ".vst3"` — stable and beta each recognise only their own binary) is FX chrome we own, not a hand-off-worthy plugin editor, so it is never `Plugin` and never counts as `isPluginEditorChain` proof — a drag over our own editor stays the instrument-drop outcome. Every other plugin's module is unaffected. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`. - `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure. - `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers. - `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching). 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..c4c8554 --- /dev/null +++ b/src/core/ui/window_origin.cpp @@ -0,0 +1,62 @@ +// 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 (facts.ownModule) return WindowCode::Instrument; + if (normalizePath(systemRoot).empty()) return WindowCode::Unknown; + return isBeneath(facts.modulePath, systemRoot) ? WindowCode::System : WindowCode::Plugin; +} + +bool isPluginEditorChain(const std::vector& chain) { + return std::find(chain.begin(), chain.end(), WindowCode::Plugin) != chain.end(); +} + +bool isOwnModulePath(const std::string& path, const std::string& ownName) { + const std::size_t pos = path.find_last_of("\\/"); + const std::string base = pos == std::string::npos ? path : path.substr(pos + 1); + if (base.size() != ownName.size()) return false; + for (std::size_t i = 0; i < base.size(); ++i) { + char a = base[i], b = ownName[i]; + if (a >= 'A' && a <= 'Z') a = static_cast(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') b = static_cast(b - 'A' + 'a'); + if (a != b) return false; + } + return true; +} + +} // 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..05afa94 --- /dev/null +++ b/src/core/ui/window_origin.h @@ -0,0 +1,49 @@ +#pragma once +// window_origin — whose code implements a window, and whether the window chain under the pointer +// is a plugin's own editor rather than REAPER's chrome. The shell reads the facts (owning process, +// the module holding the window CLASS's procedure); this module decides, so the rule is provable +// without a DAW. Evidence feeding ui::DropContext::pointerOverPluginEditor. + +#include +#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) + Instrument, // our own ReaSampler 9000 module: FX chrome, not a hand-off-worthy plugin editor + Unknown, // the procedure resolved to no loaded module — no evidence either way +}; + +// What the shell observed about ONE window. The CLASS procedure, not the instance one, because an +// extension subclassing a REAPER window swaps only the instance procedure — reading that would +// make REAPER's own chrome look like foreign code. +struct WindowFacts { + bool otherProcess = false; // the window belongs to a process other than the host's + bool procResolved = false; // the class procedure lies inside a loaded module + bool hostModule = false; // ...and that module is the host executable + // ...and that module is THIS channel's ReaSampler 9000 .vst3. Rationale: core/ui/CLAUDE.md's + // window_origin entry (the ownModule/Instrument exemption). + bool ownModule = false; + std::string modulePath; // that module's file path, UTF-8 +}; + +// `systemRoot` is the OS directory (GetSystemWindowsDirectory); a module beneath it is System. +// An empty root makes every non-host module Unknown rather than Plugin: without it a standard +// button in REAPER's chrome would be indistinguishable from plugin code. +WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& systemRoot); + +// `chain` runs from the window under the pointer up to and including its top-level root. True iff +// any window in it is Plugin code: a plugin's editor sits beneath REAPER's container, and a +// standard control INSIDE that editor is still under the plugin's own window. Unknown is never +// proof — the hand-off this gates is modal, so it needs positive evidence. +bool isPluginEditorChain(const std::vector& chain); + +// True iff `path`'s file name (the part after the last separator, forward or back) matches +// `ownName` case-insensitively — see WindowFacts::ownModule for why this decides Instrument vs +// Plugin. `path` may be a bundle path (only the final component is compared). +bool isOwnModulePath(const std::string& path, const std::string& ownName); + +} // namespace reasampler::ui 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..1e78de0 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. It also sets `WindowFacts::ownModule` — a basename match of the resolved module against this channel's own `reasampler_9000[_beta].vst3` (`app_version::vstOutputName() + ".vst3"`) — so our own instrument's editor classifies as `Instrument`, not `Plugin`, and a drag over it stays the instrument-drop outcome instead of handing off to the OS; every other plugin's editor is unaffected. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin means the extension writes nothing to the bank and inserts no timeline item — the plugin receives a copy-only path list, exactly as from Explorer (the plugin's own reaction to that path list is outside the extension's control). +- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER or over a plugin's own editor (so an FX surface reaches the instrument drop only as its chrome; a plugin's editor inside it hands off to the OS) — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** - `insert_fx_action` — the "Insert as FX" verb: a second ENTRY POINT to `instrument_drop_win`'s drop body (the bank panel's Placement button and a bindable action), loading the panel's focused capture onto the first selected track. Owns no insert logic of its own — it resolves the selection, folds `core/ui/insert_fx_enable`, and calls `performInstrumentDrop` (which keeps the one undo block). Refuses visibly on every failing precondition; the panel button additionally paints dead for the two panel-local ones. - `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank. - `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless. diff --git a/src/shell/actions/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp index cb2d171..1e5c661 100644 --- a/src/shell/actions/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -12,6 +12,10 @@ #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 +#include "core/version/app_version.h" // vstOutputName — this channel's own .vst3 basename + namespace reasampler { namespace { @@ -86,10 +90,16 @@ bool ensureOleForThisThread() { return state > 0; } -// Minimal IDropSource: continue until the (left) button releases or Escape cancels; always -// request the copy cursor. This is the standard textbook drop source — no custom feedback. +// IDropSource: the textbook button/Escape source, plus the law re-asked every turn so the drag can +// be withdrawn back to the panel (ui::decideOsDragTurn). Always requests the default cursors. +// [verify — DAW] QueryContinueDrag's cadence: MSDN words it as called on a keyboard or +// mouse-BUTTON state change, so on plain moves it may not run; if so a mid-drag withdrawal waits +// for the next state change, but the release — a button change — is still decided here. class DropSource final : public IDropSource { public: + explicit DropSource(bool (*lawHandsOff)()) : lawHandsOff_(lawHandsOff) {} + ui::OsDragTurn withdrawal() const { return withdrawal_; } + // IUnknown HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppv) override { if (riid == IID_IUnknown || riid == IID_IDropSource) { @@ -108,15 +118,33 @@ public: } // IDropSource HRESULT STDMETHODCALLTYPE QueryContinueDrag(BOOL escapePressed, DWORD keyState) override { - if (escapePressed) return DRAGDROP_S_CANCEL; - if (!(keyState & MK_LBUTTON)) return DRAGDROP_S_DROP; // released -> drop - return S_OK; // keep dragging + const bool escape = escapePressed != FALSE; + // Escape ignores the law, so it is not probed then; no callback is the textbook source. + const bool lawHandsOff = escape || !lawHandsOff_ || lawHandsOff_(); + const ui::OsDragTurn turn = + ui::decideOsDragTurn(escape, (keyState & MK_LBUTTON) != 0, lawHandsOff); + switch (turn) { + case ui::OsDragTurn::Continue: + return S_OK; + case ui::OsDragTurn::Drop: + return DRAGDROP_S_DROP; + case ui::OsDragTurn::Cancel: + return DRAGDROP_S_CANCEL; + case ui::OsDragTurn::Withdraw: + case ui::OsDragTurn::WithdrawAtRelease: + withdrawal_ = turn; // the target sees a cancel (DragLeave), never a drop + return DRAGDROP_S_CANCEL; + } + return DRAGDROP_S_CANCEL; } HRESULT STDMETHODCALLTYPE GiveFeedback(DWORD /*effect*/) override { return DRAGDROP_S_USEDEFAULTCURSORS; // let OLE draw the standard copy cursor } private: volatile LONG refs_ = 1; + bool (*lawHandsOff_)() = nullptr; + // Continue doubles as "not withdrawn": it is never the turn that ends the loop. + ui::OsDragTurn withdrawal_ = ui::OsDragTurn::Continue; }; // Minimal IDataObject exposing exactly one format (CF_HDROP / TYMED_HGLOBAL). The HDROP is @@ -214,25 +242,34 @@ private: } // namespace -bool initiateDragOut(HWND__* /*panelHwnd*/, const std::vector& 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 +302,98 @@ bool pointerOverHostWindow(int screenX, int screenY) { return pid == GetCurrentProcessId(); } +namespace { + +std::string toUtf8(const std::wstring& w) { + if (w.empty()) return {}; + const int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(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); + } +} + +// This channel's own ReaSampler 9000 module file name (e.g. "reasampler_9000.vst3", or the +// "_beta" variant) — the same name CMake gives the built .vst3 (OUTPUT_NAME + SUFFIX). Matching +// logic lives in core/ui/window_origin's isOwnModulePath; see there. +const std::string& ownVstFileName() { + static const std::string name = version::vstOutputName() + ".vst3"; + return name; +} + +// Empty on failure, which window_origin reads as "cannot tell OS code from plugin code". +const std::string& systemRoot() { + static const std::string root = [] { + wchar_t dir[MAX_PATH] = {0}; + const UINT n = GetSystemWindowsDirectoryW(dir, MAX_PATH); + return (n > 0 && n < MAX_PATH) ? toUtf8(std::wstring(dir, n)) : std::string{}; + }(); + return root; +} + +ui::WindowFacts windowFacts(HWND hwnd) { + ui::WindowFacts f; + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != GetCurrentProcessId()) { + f.otherProcess = true; + return f; + } + // The charset-matched read returns the procedure's address; a mismatched one can return a + // handle that lies in no module, which is left as unresolved rather than guessed at. + const ULONG_PTR proc = IsWindowUnicode(hwnd) ? GetClassLongPtrW(hwnd, GCLP_WNDPROC) + : GetClassLongPtrA(hwnd, GCLP_WNDPROC); + HMODULE mod = nullptr; + if (!proc || !GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(proc), &mod) || + !mod) { + return f; + } + f.procResolved = true; + f.hostModule = mod == GetModuleHandleW(nullptr); + // classifyWindowCode returns Host on hostModule before ever reading modulePath — skip the + // (string-building) lookup in that case. + if (!f.hostModule) { + f.modulePath = moduleFileName(mod); + f.ownModule = ui::isOwnModulePath(f.modulePath, ownVstFileName()); + } + return f; +} + +} // namespace + +bool pointerOverPluginEditor(int screenX, int screenY) { + // The CLASS procedure's module rather than the window's hInstance: a bare-SDK plugin may + // create its editor with the hInstance of the parent REAPER hands it, so hInstance would + // read that plugin as REAPER. + const POINT pt{screenX, screenY}; + const HWND hit = WindowFromPoint(pt); + if (!hit) return false; + const HWND root = GetAncestor(hit, GA_ROOT); + + constexpr std::size_t kMaxDepth = 64; // a guard against a malformed tree, not a real bound + std::vector chain; + for (HWND h = hit; h && chain.size() < kMaxDepth; h = GetAncestor(h, GA_PARENT)) { + chain.push_back(ui::classifyWindowCode(windowFacts(h), systemRoot())); + if (h == root) break; + } + return ui::isPluginEditorChain(chain); +} + } // namespace reasampler #else // ---- macOS / Linux (SWELL) ----------------------------------------------------- @@ -278,8 +407,9 @@ namespace reasampler { // SWELL_InitiateDragDropOfFileList initiates a copy-style file drag from the given // window. Unlike OLE it exposes no per-source effect mask, so the copy-only // guarantee here rests on SWELL's copy semantics rather than an explicit mask. -bool initiateDragOut(HWND__* panelHwnd, const std::vector& 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 +418,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 +438,11 @@ bool pointerOverHostWindow(int screenX, int screenY) { return WindowFromPoint(pt) != nullptr; } +// No plugin-editor hand-off off Windows: every FX window keeps its instrument outcome, as before. +bool pointerOverPluginEditor(int /*screenX*/, int /*screenY*/) { + return false; +} + } // namespace reasampler #endif diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index f13a9b9..5916ee1 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,36 @@ 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; +// - an all-#32770 dialog-based editor (the generic Windows dialog class) resolves to no +// recognizable plugin module — falls to REAPER's outcome; +// - a .NET/WPF editor's class procedure is a CLR-thunked address with no resolvable module — +// also falls to REAPER's outcome; `[verify]`; +// - our own ReaSampler 9000 module (this channel's reasampler_9000[_beta].vst3, matched by +// basename) also falls to REAPER's outcome, deliberately — see ui::WindowFacts::ownModule; +// - ReaSampler 9000 itself run bridged/in a separate process reads as Plugin, same as any other +// bridged plugin — banner (the OS hand-off fires over our own editor), harmless; +// - a renamed ReaSampler 9000 binary (the basename no longer matches ownVstFileName()) misses +// the exemption and reads as Plugin — banner, harmless. +// The opposite direction is unguarded: a third-party hook that replaces REAPER's own chrome +// class procedure via SetClassLongPtr(GCLP_WNDPROC) would make REAPER's own window read as +// Plugin — toward OLE instead of away from it. +// [verify — DAW] that REAPER's GetThingFromPoint names an fx_* token over the plugin's own child +// window — the law requires it, so an unnamed editor would refuse rather than hand off. +// [verify — DAW] that OLE never walks up to an ancestor drop target (e.g. REAPER's own file +// import) when the plugin editor's window registers no IDropTarget of its own. +// [verify — DAW] that GetThingFromPoint cannot name a REAPER region (e.g. an fx_* token) when a +// foreign window overlaps it at the same screen point — this fn trusts WindowFromPoint's hit +// over whatever GetThingFromPoint separately reports. +// SWELL: always false (no plugin-editor hand-off off Windows). Side-effect-free. +bool pointerOverPluginEditor(int screenX, int screenY); + } // namespace reasampler diff --git a/src/shell/instrument/editor_platform.cpp b/src/shell/instrument/editor_platform.cpp index b465139..96164cc 100644 --- a/src/shell/instrument/editor_platform.cpp +++ b/src/shell/instrument/editor_platform.cpp @@ -13,6 +13,7 @@ #include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) #include "shell/instrument/reasampler_processor.h" +#include "core/version/app_version.h" // vstOutputName — channel-forked window class name using namespace Steinberg; @@ -20,7 +21,32 @@ namespace reasampler::vst { #ifdef _WIN32 namespace { -constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; + +// Channel-forked so stable and beta never fight over one class registration (a second +// RegisterClassW against an already-taken name silently fails, leaving that channel's editors +// running the FIRST-registered channel's wndProc). vstOutputName() is ASCII (CMake OUTPUT_NAME), +// so a plain widen is safe. +const std::wstring& childClassName() { + static const std::wstring name = [] { + const std::string& base = version::vstOutputName(); + return std::wstring(base.begin(), base.end()) + L"VstEditor"; + }(); + return name; +} + +// The module holding a given code address — used to resolve THIS wndProc's own module rather +// than the parent's hInstance REAPER hands us. Registering against the parent would put the class +// in REAPER's own module while the wndproc still points into our DLL — a dangling procedure +// pointer once our DLL unloads but REAPER's class registration outlives it. Takes the address +// rather than naming wndProc directly so a free function in this anonymous namespace doesn't need +// access to the private member. +HMODULE moduleHandleFor(void* codeAddress) { + HMODULE mod = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(codeAddress), &mod); + return mod; +} // The change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread // timer: responsive enough that a recapture/ingest/assign refreshes within a bounded @@ -105,8 +131,9 @@ void ReaSamplerEditor::attachedToParent() { HWND parent = static_cast(systemWindow); if (!parent) return; - HINSTANCE hInst = - reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); + // Own module, not the parent's hInstance — see moduleHandleFor() above. + HINSTANCE hInst = reinterpret_cast( + moduleHandleFor(reinterpret_cast(&ReaSamplerEditor::wndProc))); if (!hInst) hInst = GetModuleHandle(nullptr); static bool classRegistered = false; @@ -114,7 +141,7 @@ void ReaSamplerEditor::attachedToParent() { WNDCLASSW wc{}; wc.lpfnWndProc = &ReaSamplerEditor::wndProc; wc.hInstance = hInst; - wc.lpszClassName = kChildClassName; + wc.lpszClassName = childClassName().c_str(); wc.hCursor = LoadCursor(nullptr, IDC_ARROW); // CS_DBLCLKS is what makes WM_LBUTTONDBLCLK arrive at all. It also REPLACES the second // WM_LBUTTONDOWN of a double-click, so every surface that counted two downs (Browse's @@ -134,7 +161,7 @@ void ReaSamplerEditor::attachedToParent() { refreshFromBank(); const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + childHwnd_ = CreateWindowExW(0, childClassName().c_str(), L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); if (childHwnd_) { SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); diff --git a/src/shell/panel/CLAUDE.md b/src/shell/panel/CLAUDE.md index 2fe6f43..4f2adfd 100644 --- a/src/shell/panel/CLAUDE.md +++ b/src/shell/panel/CLAUDE.md @@ -2,9 +2,9 @@ ## Scope -The REAPER-facing shell for the docked bank panel: the eight `bank_panel` split TUs +The REAPER-facing shell for the docked bank panel: the nine `bank_panel` split TUs (`panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / -`panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via +`panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h`), plus `draw_kit`, the shared LICE draw shell also consumed by the VST3 editor (`shell/instrument/`). @@ -47,12 +47,13 @@ live in `shell/bank_ops`, a sibling directory, not here. ## Modules -- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU. +- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_drag_os` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into nine TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Placement, frequent acts only, via `action_bar`, short labels; Maintenance and the rarer Placement/Capture variants sit in the More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_drag_os` the OS hand-off/resume seam split out of `panel_drag`; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU. - `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route. - `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read). - `panel_render` — the WM_PAINT draw. - `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer. - `panel_drag` — the hover + card-drag state machine + drop dispatch. + - `panel_drag_os` — the OS hand-off/resume seam: the live re-ask the OS drag loop uses, the hand-off itself, and resuming the internal drag when the OS loop gives the gesture back. - `panel_thumbnails` — the PCM→envelope thumbnail cache + the bank-change fingerprint pass. - `panel_audition` — the preview-playback engine. - `panel_bank_ops` — the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. diff --git a/src/shell/panel/panel_drag.cpp b/src/shell/panel/panel_drag.cpp index 001143b..19a857f 100644 --- a/src/shell/panel/panel_drag.cpp +++ b/src/shell/panel/panel_drag.cpp @@ -148,11 +148,13 @@ void applyDragCursor(CardGesture g) { SetCursor(LoadCursor(nullptr, idc)); } +} // namespace + // The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The // cue is set on EVERY move, so "will this work" is visible before the button comes up, and a // refusal is a cursor the user can see rather than a release that does nothing. Cursor-only by // design — the docked panel is usually not under the pointer during an out-of-client drag, so -// panel status text would be invisible exactly when it is needed. +// panel status text would be invisible exactly when it is needed. Shared with panel_drag_os.cpp. void applyDropCue(DropCue cue) { const char* idc = nullptr; switch (cue) { @@ -169,15 +171,7 @@ void applyDropCue(DropCue cue) { SetCursor(LoadCursor(nullptr, idc)); } -// One independent evaluation of the drag's target: the resolved class plus the live facts its -// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is -// per-move and stateless"). -struct LiveDrop { - DropClass cls = DropClass::None; - MediaTrack* track = nullptr; - int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client -}; - +// Shared with panel_drag_os.cpp (lawHandsOffAtCursor). LiveDrop resolveLiveDrop(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); @@ -199,10 +193,11 @@ LiveDrop resolveLiveDrop(int x, int y) { const DropProbe probe = probeDropTarget(sp.x, sp.y); ctx.surface = probe.surface; ctx.haveTrack = probe.track != nullptr; - // Two probes because they answer two independent questions: what REAPER named, and - // whether we are still inside REAPER at all. The law's hand-off gate reads both — see - // ui::DropContext::pointerOffHost. + // Separate probes because they answer independent questions: what REAPER named, whether + // we are still inside REAPER at all, and whether plugin code owns the window. The law's + // hand-off gates pair them — see ui::DropContext::pointerOffHost. ctx.pointerOffHost = !pointerOverHostWindow(sp.x, sp.y); + ctx.pointerOverPluginEditor = pointerOverPluginEditor(sp.x, sp.y); out.track = probe.track; out.screenX = sp.x; } @@ -219,39 +214,7 @@ LiveDrop resolveLiveDrop(int x, int y) { return out; } -// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal -// loop, so the internal drag must be fully wound down first, and only once the payload is known -// to be hand-off-able. This runs on every qualifying move — do not memoize a failed attempt. -// -// Residual (accepted): once the pointer has genuinely left every REAPER window, dragging back IN -// mid-modal-loop delivers a CF_HDROP to REAPER's own file-import drop target rather than to our -// gesture law. NOT confirmed by experiment — inferred from REAPER's handling of external file -// drops, and the inferred outcome (an item at the drop point) coincides with what our own -// arrange path would have done. This is now the ONLY way to see that import ghost mid-drag; -// reaching it from a surface still inside REAPER was the defect pointerOffHost closed. -void handOffToOs() { - // Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let - // the pure rule couple the two side effects: an unresolvable payload leaves the internal - // drag intact rather than winding it down for a hand-off that never runs — a half-torn-down - // drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same - // coupling to the OS-readiness failures the pure decision cannot see. - const std::vector paths = resolveDragPathsForOs(); - if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) { - applyDropCue(DropCue::Refuse); - invalidatePanel(); - return; - } - - if (GetCapture() == g_panel.hwnd) ReleaseCapture(); - resetDragState(); - invalidatePanel(); - - initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows - - // The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes - // onLBtnUp's not-dragging branch and never restores the arrow — do it here instead. - SetCursor(LoadCursor(nullptr, IDC_ARROW)); -} +namespace { // Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback, // mirroring handleClick's precedence exactly (so the element that lights on hover is @@ -339,7 +302,24 @@ void maybeShowTooltip() { } } -void onMouseMove(int x, int y) { +void onMouseMove(int x, int y, WPARAM wParam) { + // A move with the button already up while still dragging means the release was missed (the + // OS hand-off's own modal loop can eat a WM_LBUTTONUP, and coalescing can lose one too) — + // treat it as the release now rather than leaving dragging stuck for the next plain click. +#ifdef _WIN32 + if (g_panel.dragging && !(wParam & MK_LBUTTON)) { + onLBtnUp(x, y); + return; + } +#else + // SWELL never sets wParam on WM_MOUSEMOVE (swell-types.h ~1315), so the button-up test must + // read the physical key state instead of the message's own (always-zero) wParam. + if (g_panel.dragging && !(GetAsyncKeyState(VK_LBUTTON) & 0x8000)) { + onLBtnUp(x, y); + return; + } +#endif + // Hover feedback: resolve + repaint-on-change, but NOT during a drag (the drag owns // the visual feedback then — a drop-target highlight, not a hover). if (!g_panel.dragging && !g_panel.dragArmed) updateHover(x, y); diff --git a/src/shell/panel/panel_drag_os.cpp b/src/shell/panel/panel_drag_os.cpp new file mode 100644 index 0000000..bbc3daa --- /dev/null +++ b/src/shell/panel/panel_drag_os.cpp @@ -0,0 +1,113 @@ +// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp: the live +// re-ask the OS drag loop uses to decide whether to keep the drag, the hand-off itself, and +// resuming the internal drag when the OS loop gives the gesture back. +// +// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called directly here; +// REAPER SDK types arrive via panel_state.h. + +#include +#include + +#include "shell/panel/panel_state.h" + +#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam + +namespace reasampler::panel { + +namespace { + +// The physical left button, honouring a swapped primary button (SM_SWAPBUTTON) — distinct from +// dragging/dragArmed, which only say what OUR state machine last believed. Read fresh rather than +// trusted, because DoDragDrop's own modal loop can consume the WM_LBUTTONUP that would otherwise +// have told us the button came up. +bool leftButtonPhysicallyDown() { + const int vk = GetSystemMetrics(SM_SWAPBUTTON) ? VK_RBUTTON : VK_LBUTTON; + return (GetAsyncKeyState(vk) & 0x8000) != 0; +} + +// Re-asked by the OS drag loop on every turn. The payload fields the law reads survive +// resetDragState, so it still sees the drag the hand-off wound down. With the panel gone +// mid-loop there is no gesture left to give back, so the OS keeps the drag. +bool lawHandsOffAtCursor() { + if (!g_panel.hwnd) return true; + POINT p{}; + GetCursorPos(&p); + ScreenToClient(g_panel.hwnd, &p); + return resolveLiveDrop(p.x, p.y).cls == DropClass::OsHandoff; +} + +// The OS loop gave the gesture back (ui::OsDragTurn::Withdraw*): restore exactly the fields +// resetDragState cleared that the drag still needs, then either keep dragging under our own +// capture or — the release was consumed by the OS loop, so no WM_LBUTTONUP will follow — commit +// at the release point now. No move is replayed here: the next WM_MOUSEMOVE re-resolves the cue, +// and replaying one could re-enter handOffToOs from inside itself. +void resumeInternalDrag(const std::string& primaryId, bool released) { + if (!g_panel.hwnd) return; + g_panel.dragArmed = true; + g_panel.dragging = true; + g_panel.dragPrimaryId = primaryId; + + POINT p{}; + GetCursorPos(&p); + ScreenToClient(g_panel.hwnd, &p); + if (released) { + onLBtnUp(p.x, p.y); + return; + } + // [verify — DAW] that SetCapture re-takes the mouse mid-press after DoDragDrop released it. + SetCapture(g_panel.hwnd); + // The button can have come up between DoDragDrop's own release and this re-capture (the + // Withdrawn turn only promises the button was down AT the last OS-loop turn, not at this + // instant) — without this check dragging stays armed with a stale payload, and the very next + // plain click commits a reorder/replace nobody asked for. + if (!leftButtonPhysicallyDown()) { + onLBtnUp(p.x, p.y); + return; + } + invalidatePanel(); +} + +} // namespace + +// The hand-off: DoDragDrop takes mouse capture and runs its own modal loop, so the internal drag +// must be fully wound down first, and only once the payload is known to be hand-off-able. This +// runs on every qualifying move — do not memoize a failed attempt. The OS loop may hand the +// gesture back (ui::OsDragTurn owns why); a side effect worth keeping is that, on Windows, a +// return trip can no longer drop a CF_HDROP on REAPER's file import or on this panel's own +// WM_DROPFILES ingest — the release there is withdrawn, not dropped. +void handOffToOs() { + // Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let + // the pure rule couple the two side effects: an unresolvable payload leaves the internal + // drag intact rather than winding it down for a hand-off that never runs — a half-torn-down + // drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same + // coupling to the OS-readiness failures the pure decision cannot see. + const std::vector paths = resolveDragPathsForOs(); + if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) { + applyDropCue(DropCue::Refuse); + invalidatePanel(); + return; + } + + const std::string primaryId = g_panel.dragPrimaryId; // resetDragState clears it + if (GetCapture() == g_panel.hwnd) ReleaseCapture(); + resetDragState(); + invalidatePanel(); + + // COPY-ONLY; blocking on Windows. + switch (initiateDragOut(g_panel.hwnd, paths, &lawHandsOffAtCursor)) { + case OsDragEnd::Withdrawn: + resumeInternalDrag(primaryId, /*released=*/false); + return; + case OsDragEnd::WithdrawnAtRelease: + resumeInternalDrag(primaryId, /*released=*/true); + return; + case OsDragEnd::Concluded: + break; + } + + // The drag state was reset above, so the WM_LBUTTONUP that ends the modal loop takes + // onLBtnUp's not-dragging branch and never restores the arrow — do it here instead. + SetCursor(LoadCursor(nullptr, IDC_ARROW)); +} + +} // namespace reasampler::panel diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 2a77e79..3a43271 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -536,11 +536,27 @@ void registerAccel(); void unregisterAccel(); // panel_drag.cpp — the card-drag/hover state machine (pure mirror: core/ui/card_drag). -// Per-mouse-move work stays plain free-function calls. -void onMouseMove(int x, int y); +// Per-mouse-move work stays plain free-function calls. wParam carries WM_MOUSEMOVE's own +// button-state flags (MK_LBUTTON etc) so a move with the button already up can be told apart +// from GetAsyncKeyState's live-polled state. +void onMouseMove(int x, int y, WPARAM wParam); void onLBtnUp(int x, int y); void handleRightClick(int x, int y); void resetDragState(); void maybeShowTooltip(); +// One independent evaluation of the drag's target: the resolved class plus the live facts its +// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is +// per-move and stateless"). Shared with panel_drag_os.cpp. +struct LiveDrop { + DropClass cls = DropClass::None; + MediaTrack* track = nullptr; + int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client +}; +LiveDrop resolveLiveDrop(int x, int y); +void applyDropCue(DropCue cue); + +// panel_drag_os.cpp — the OS hand-off / resume seam split out of panel_drag.cpp. +void handOffToOs(); + } // namespace reasampler::panel diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp index edb7e3a..d895a4f 100644 --- a/src/shell/panel/panel_window.cpp +++ b/src/shell/panel/panel_window.cpp @@ -152,7 +152,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { return 0; } case WM_MOUSEMOVE: - onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam); return 0; case WM_LBUTTONUP: onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); 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..d81fb77 --- /dev/null +++ b/tests/test_window_origin.cpp @@ -0,0 +1,237 @@ +// Standalone tests for reasampler::ui::window_origin — no REAPER, no Win32, no test framework. +// Asserts the per-window code fold and the chain rule the plugin-editor hand-off proof rests on. + +#include "../src/core/ui/window_origin.h" + +#include +#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; +} + +static WindowFacts ownModule(const std::string& path) { + WindowFacts f; + f.procResolved = true; + f.modulePath = path; + f.ownModule = true; + return f; +} + +// --- classifyWindowCode -------------------------------------------------------- + +// REAPER's own chrome and containers: the class procedure lives in the executable. +static void testHostExecutableIsHost() { + CHECK(classifyWindowCode(inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true), + kWinDir) == WindowCode::Host); +} + +// Standard controls and the dialog class are implemented by OS modules, wherever the OS keeps +// them — System32, SysWOW64 for a 32-bit host, and the side-by-side store comctl32 v6 loads from. +static void testOsModulesAreSystem() { + CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), kWinDir) == + WindowCode::System); + CHECK(classifyWindowCode(inModule("C:\\Windows\\SysWOW64\\user32.dll"), kWinDir) == + WindowCode::System); + CHECK(classifyWindowCode( + inModule("C:\\Windows\\WinSxS\\amd64_microsoft.windows.common-controls_6595b64144ccf1df" + "_6.0.26100.1_none_x\\comctl32.dll"), + kWinDir) == WindowCode::System); +} + +// The same directory spelled differently by two APIs still compares equal: case, separator +// direction, a trailing separator on the root, and the long-path prefix. +static void testSystemMatchIgnoresSpelling() { + CHECK(classifyWindowCode(inModule("c:/WINDOWS/system32/USER32.DLL"), kWinDir) == + WindowCode::System); + CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "C:\\WINDOWS\\") == + WindowCode::System); + CHECK(classifyWindowCode(inModule("\\\\?\\C:\\Windows\\System32\\user32.dll"), kWinDir) == + WindowCode::System); +} + +// Containment, not string prefix: a sibling directory whose name merely starts with the OS +// directory's is plugin territory, and so is the OS directory itself named as a file path. +static void testSiblingDirectoryIsNotSystem() { + CHECK(classifyWindowCode(inModule("C:\\WindowsApps\\thing.dll"), kWinDir) == + WindowCode::Plugin); + CHECK(classifyWindowCode(inModule("C:\\Windows2\\thing.dll"), kWinDir) == WindowCode::Plugin); + CHECK(classifyWindowCode(inModule("C:\\Windows"), kWinDir) == WindowCode::Plugin); +} + +// A plugin binary anywhere outside the OS directory — the VST3 bundle, a VST2 folder, a helper +// DLL a plugin loads beside itself — is Plugin. +static void testPluginBinariesArePlugin() { + CHECK(classifyWindowCode( + inModule("C:\\Program Files\\Common Files\\VST3\\Serum2.vst3\\Contents\\x86_64-win\\" + "Serum2.vst3"), + kWinDir) == WindowCode::Plugin); + CHECK(classifyWindowCode(inModule("D:\\VstPlugins\\Qt5Widgets.dll"), kWinDir) == + WindowCode::Plugin); +} + +// Another process's window is plugin code by the process boundary alone, whatever its other facts +// claim: a procedure address from another process resolved in ours is meaningless. +static void testOtherProcessIsPluginRegardlessOfModule() { + WindowFacts f = inModule("C:\\Program Files\\REAPER (x64)\\reaper.exe", true); + f.otherProcess = true; + CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Plugin); + WindowFacts g; + g.otherProcess = true; // nothing resolved at all + CHECK(classifyWindowCode(g, kWinDir) == WindowCode::Plugin); +} + +// No module (a charset-mismatch handle, generated code) is no evidence — never Plugin. +static void testUnresolvedProcIsUnknown() { + WindowFacts f; + f.modulePath = "C:\\VST\\x.dll"; // ignored without a resolved procedure + CHECK(classifyWindowCode(f, kWinDir) == WindowCode::Unknown); +} + +// Without the OS directory a standard control cannot be told from plugin code, so every non-host +// module degrades to Unknown — the failure must not read REAPER's buttons as a plugin. +static void testMissingSystemRootNeverYieldsPlugin() { + CHECK(classifyWindowCode(inModule("C:\\Windows\\System32\\user32.dll"), "") == + WindowCode::Unknown); + CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "") == WindowCode::Unknown); + CHECK(classifyWindowCode(inModule("C:\\VST\\x.dll"), "\\") == WindowCode::Unknown); + CHECK(classifyWindowCode(inModule("C:\\REAPER\\reaper.exe", true), "") == WindowCode::Host); +} + +// Our own ReaSampler 9000 module — stable or beta, whichever the shell set ownModule for — is +// Instrument, not Plugin: FX chrome we own, so a drag over it must not hand off to the OS. +static void testOwnModuleIsInstrument() { + CHECK(classifyWindowCode( + ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000.vst3"), kWinDir) == + WindowCode::Instrument); + CHECK(classifyWindowCode( + ownModule("C:\\Program Files\\Common Files\\VST3\\reasampler_9000_beta.vst3"), + kWinDir) == WindowCode::Instrument); +} + +// --- isPluginEditorChain ------------------------------------------------------- + +// Pointer on REAPER's FX-window chrome: a standard control or a REAPER container, under the +// dialog-class root. No plugin code anywhere in the chain. +static void testChromeChainIsNotPlugin() { + CHECK(!isPluginEditorChain({WindowCode::System, WindowCode::Host, WindowCode::System})); + CHECK(!isPluginEditorChain({WindowCode::Host, WindowCode::System})); + CHECK(!isPluginEditorChain({WindowCode::Host})); +} + +// Pointer on the plugin's editor window itself. +static void testPluginWindowChainIsPlugin() { + CHECK(isPluginEditorChain({WindowCode::Plugin, WindowCode::Host, WindowCode::System})); +} + +// A standard control INSIDE the plugin's editor (a native text box) is System on its own; the +// plugin's window above it is what makes the point the plugin's. +static void testStandardControlInsidePluginIsPlugin() { + CHECK(isPluginEditorChain( + {WindowCode::System, WindowCode::Plugin, WindowCode::Host, WindowCode::System})); +} + +// A bridged plugin's editor embedded across the process boundary: the hit window is Plugin by +// process, REAPER's container and root above it are in-process. +static void testBridgedEmbeddedEditorIsPlugin() { + WindowFacts bridged; + bridged.otherProcess = true; + CHECK(isPluginEditorChain({classifyWindowCode(bridged, kWinDir), WindowCode::Host, + WindowCode::System})); +} + +// A pointer over our own instrument's editor — Instrument code, not Plugin — is not hand-off +// proof: it stays REAPER's FX chrome, unlike a genuine plugin editor (testPluginWindowChainIsPlugin). +static void testOwnInstrumentChainIsNotPlugin() { + CHECK(!isPluginEditorChain({WindowCode::Instrument, WindowCode::Host, WindowCode::System})); +} + +// Another plugin's editor sitting alongside our own instrument in the same chain is still proof — +// only OUR module is exempted, nobody else's. +static void testOtherPluginBesideOwnInstrumentIsStillPlugin() { + CHECK(isPluginEditorChain( + {WindowCode::Plugin, WindowCode::Instrument, WindowCode::Host, WindowCode::System})); +} + +// Unknown anywhere, and nothing else, is not proof; an empty chain (no window at all) is not. +static void testUnknownAndEmptyChainsAreNotPlugin() { + CHECK(!isPluginEditorChain({WindowCode::Unknown, WindowCode::Host, WindowCode::System})); + CHECK(!isPluginEditorChain({WindowCode::Unknown})); + CHECK(!isPluginEditorChain({})); +} + +// --- isOwnModulePath ----------------------------------------------------------- + +static void testOwnModulePathCaseFolds() { + CHECK(isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000.vst3")); + CHECK(isOwnModulePath("C:\\VST3\\ReaSampler_9000.VST3", "reasampler_9000.vst3")); +} + +static void testOwnModulePathBundlePathForwardAndBackSlash() { + CHECK(isOwnModulePath( + "C:\\VST3\\reasampler_9000.vst3\\Contents\\x86_64-win\\reasampler_9000.vst3", + "reasampler_9000.vst3")); + CHECK(isOwnModulePath( + "C:/VST3/reasampler_9000.vst3/Contents/x86_64-win/reasampler_9000.vst3", + "reasampler_9000.vst3")); +} + +static void testOwnModulePathStableAndBetaDoNotMatchEachOther() { + CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000_beta.vst3", "reasampler_9000.vst3")); + CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3", "reasampler_9000_beta.vst3")); +} + +static void testOwnModulePathBareFilenameWithNoSeparatorMatches() { + CHECK(isOwnModulePath("reasampler_9000.vst3", "reasampler_9000.vst3")); +} + +static void testOwnModulePathEmptyPathDoesNotMatch() { + CHECK(!isOwnModulePath("", "reasampler_9000.vst3")); +} + +static void testOwnModulePathTrailingSeparatorDoesNotMatch() { + CHECK(!isOwnModulePath("C:\\VST3\\reasampler_9000.vst3\\", "reasampler_9000.vst3")); +} + +int main() { + testHostExecutableIsHost(); + testOsModulesAreSystem(); + testSystemMatchIgnoresSpelling(); + testSiblingDirectoryIsNotSystem(); + testPluginBinariesArePlugin(); + testOtherProcessIsPluginRegardlessOfModule(); + testUnresolvedProcIsUnknown(); + testMissingSystemRootNeverYieldsPlugin(); + testOwnModuleIsInstrument(); + + testChromeChainIsNotPlugin(); + testPluginWindowChainIsPlugin(); + testStandardControlInsidePluginIsPlugin(); + testBridgedEmbeddedEditorIsPlugin(); + testOwnInstrumentChainIsNotPlugin(); + testOtherPluginBesideOwnInstrumentIsStillPlugin(); + testUnknownAndEmptyChainsAreNotPlugin(); + + testOwnModulePathCaseFolds(); + testOwnModulePathBundlePathForwardAndBackSlash(); + testOwnModulePathStableAndBetaDoNotMatchEachOther(); + testOwnModulePathBareFilenameWithNoSeparatorMatches(); + testOwnModulePathEmptyPathDoesNotMatch(); + testOwnModulePathTrailingSeparatorDoesNotMatch(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}