diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index 6c2c720..97274db 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -98,7 +98,9 @@ L7 sub-pass, 2026-07-27): 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). + 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`, @@ -116,7 +118,7 @@ L7 sub-pass, 2026-07-27): - `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 the two proof members of `DropContext` — `pointerOffHost` (`drag_out_win::pointerOverHostWindow`) where REAPER's hit-test named nothing (`Other`, no track), and `pointerOverPluginEditor` (`drag_out_win::pointerOverPluginEditor`) where it named `FxSurface` — both gated ahead of the surface switch, so no evidence source can give a live drag away to OLE on its own (the invariant above states why each pairing). Also owns `decideOsDragTurn`, the OS drag loop's per-turn Continue / Drop / Cancel / Withdraw / WithdrawAtRelease verdict. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site. -- `window_origin` — whose code implements a window (Host / System / Plugin / Unknown, from the owning process and the module holding the window CLASS's procedure) and `isPluginEditorChain`, the rule behind `pointerOverPluginEditor`: any Plugin window from the hit window up to its root. Unknown is never proof, and without the OS directory every non-host module degrades to Unknown — both keep the failure direction toward REAPER's own outcome, never toward the modal hand-off. The shell reads the facts; the known misses are listed at `drag_out_win.h`'s `pointerOverPluginEditor`. +- `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/window_origin.cpp b/src/core/ui/window_origin.cpp index 6248ad8..18348dc 100644 --- a/src/core/ui/window_origin.cpp +++ b/src/core/ui/window_origin.cpp @@ -37,6 +37,7 @@ WindowCode classifyWindowCode(const WindowFacts& facts, const std::string& syste 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; } diff --git a/src/core/ui/window_origin.h b/src/core/ui/window_origin.h index cc10c07..de775bd 100644 --- a/src/core/ui/window_origin.h +++ b/src/core/ui/window_origin.h @@ -10,10 +10,11 @@ namespace reasampler::ui { enum class WindowCode { - Host, // the host executable itself — REAPER's own chrome and containers - System, // an OS module (standard controls, dialog class): says nothing about who placed it - Plugin, // any other code — a plugin binary, or a window of another process (bridged plugin) - Unknown, // the procedure resolved to no loaded module — no evidence either way + 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 @@ -23,6 +24,11 @@ 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 (basename match, set by the + // shell against app_version::vstOutputName() + ".vst3" — stable and beta each recognise only + // their own binary, never the other channel's). A drag over our own instrument's editor is + // the load-bearing-principle instrument-drop outcome, not the OS hand-off other plugins get. + bool ownModule = false; std::string modulePath; // that module's file path, UTF-8 }; diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index 54cec56..1e78de0 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -57,7 +57,7 @@ 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`. Owns the two window tests behind `ui::DropContext`'s hand-off proofs (`core/ui/CLAUDE.md` owns the rule they feed). `pointerOverHostWindow` is process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only; its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER. `pointerOverPluginEditor` walks from the hit window to its root reading each window's owning process and the module of its CLASS procedure (`GetClassLongPtr(GCLP_WNDPROC)` → `GetModuleHandleEx(FROM_ADDRESS)`), and lets `ui::window_origin` decide; its failure modes are listed at the declaration, and it is always false under SWELL. The OLE drop source re-asks the gesture law on every `QueryContinueDrag` (a function pointer the panel supplies) and withdraws the drag per `ui::decideOsDragTurn`, reporting it as `OsDragEnd::Withdrawn*` so the panel resumes its own gesture. A drop into a plugin 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). +- `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. diff --git a/src/shell/actions/drag_out_win.cpp b/src/shell/actions/drag_out_win.cpp index e038eec..19b0539 100644 --- a/src/shell/actions/drag_out_win.cpp +++ b/src/shell/actions/drag_out_win.cpp @@ -14,6 +14,7 @@ #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 { @@ -325,6 +326,30 @@ std::string moduleFileName(HMODULE mod) { } } +// True iff `path`'s file name (the part after the last separator) case-insensitively matches +// `fileName` — Windows paths are case-insensitive, and GetModuleFileName's casing tracks however +// the module was loaded (often not the on-disk casing). +bool sameBaseName(const std::string& path, const std::string& fileName) { + 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() != fileName.size()) return false; + for (std::size_t i = 0; i < base.size(); ++i) { + char a = base[i], b = fileName[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; +} + +// 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), so stable +// and beta each recognise only their own binary, never the other channel's. +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 = [] { @@ -358,7 +383,10 @@ ui::WindowFacts windowFacts(HWND hwnd) { 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); + if (!f.hostModule) { + f.modulePath = moduleFileName(mod); + f.ownModule = sameBaseName(f.modulePath, ownVstFileName()); + } return f; } diff --git a/src/shell/actions/drag_out_win.h b/src/shell/actions/drag_out_win.h index a85fdbf..465ffec 100644 --- a/src/shell/actions/drag_out_win.h +++ b/src/shell/actions/drag_out_win.h @@ -68,7 +68,9 @@ bool pointerOverHostWindow(int screenX, int screenY); // - 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. +// also falls to REAPER's outcome; +// - 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. // 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. diff --git a/tests/test_window_origin.cpp b/tests/test_window_origin.cpp index 4290313..60ced67 100644 --- a/tests/test_window_origin.cpp +++ b/tests/test_window_origin.cpp @@ -23,6 +23,14 @@ static WindowFacts inModule(const std::string& path, bool host = false) { 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. @@ -103,6 +111,17 @@ static void testMissingSystemRootNeverYieldsPlugin() { 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 @@ -134,6 +153,19 @@ static void testBridgedEmbeddedEditorIsPlugin() { 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})); @@ -150,11 +182,14 @@ int main() { testOtherProcessIsPluginRegardlessOfModule(); testUnresolvedProcIsUnknown(); testMissingSystemRootNeverYieldsPlugin(); + testOwnModuleIsInstrument(); testChromeChainIsNotPlugin(); testPluginWindowChainIsPlugin(); testStandardControlInsidePluginIsPlugin(); testBridgedEmbeddedEditorIsPlugin(); + testOwnInstrumentChainIsNotPlugin(); + testOtherPluginBesideOwnInstrumentIsStillPlugin(); testUnknownAndEmptyChainsAreNotPlugin(); if (g_fail == 0) std::printf("All tests passed.\n");