Merge ps-w12-drop-to-load: S17 drop-and-load (InstrumentDrop gesture + vst_chunk inject); S13 editor drop-accept, relay degraded per spec
This commit is contained in:
+23
-1
@@ -346,6 +346,20 @@ target_include_directories(action_buttons PUBLIC src)
|
|||||||
add_library(drag_out STATIC src/drag_out.cpp)
|
add_library(drag_out STATIC src/drag_out.cpp)
|
||||||
target_include_directories(drag_out PUBLIC src)
|
target_include_directories(drag_out PUBLIC src)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2l') Pure instrument_drop library — NO REAPER, NO SWELL, NO VST3 SDK. The S17
|
||||||
|
# drop-and-load blob-construction core: turn the dragged capture id into the
|
||||||
|
# base64 "vst_chunk" the extension injects via TrackFX_SetNamedConfigParm so a
|
||||||
|
# freshly-added ReaSampler 9000 plays that capture. Reuses the instrument's OWN
|
||||||
|
# serializer (sample_map::serializeComponentState) — NOT a parallel byte writer —
|
||||||
|
# so the cross-artifact blob contract cannot drift; links sample_map (which pulls
|
||||||
|
# bank_book/wav_trim/sampler_core transitively) and NEITHER SDK. The round-trip
|
||||||
|
# test decodes back through the instrument's own reader. Mirror of assignment_request.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
add_library(instrument_drop STATIC src/instrument_drop.cpp)
|
||||||
|
target_include_directories(instrument_drop PUBLIC src src/vst)
|
||||||
|
target_link_libraries(instrument_drop PUBLIC sample_map)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette
|
# 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette
|
||||||
# core of the shared drawing kit: a ROLE-based color model (bg/base..warn), the
|
# core of the shared drawing kit: a ROLE-based color model (bg/base..warn), the
|
||||||
@@ -590,6 +604,13 @@ add_executable(drag_out_tests tests/test_drag_out.cpp)
|
|||||||
target_link_libraries(drag_out_tests PRIVATE drag_out)
|
target_link_libraries(drag_out_tests PRIVATE drag_out)
|
||||||
add_test(NAME drag_out_tests COMMAND drag_out_tests)
|
add_test(NAME drag_out_tests COMMAND drag_out_tests)
|
||||||
|
|
||||||
|
# instrument_drop (S17): the drop-and-load vst_chunk blob builder. The round-trip test
|
||||||
|
# decodes the base64 back through the instrument's OWN reader (deserializeComponentState) to
|
||||||
|
# prove the extension injects exactly what setState accepts — the cross-artifact contract guard.
|
||||||
|
add_executable(instrument_drop_tests tests/test_instrument_drop.cpp)
|
||||||
|
target_link_libraries(instrument_drop_tests PRIVATE instrument_drop)
|
||||||
|
add_test(NAME instrument_drop_tests COMMAND instrument_drop_tests)
|
||||||
|
|
||||||
add_executable(theme_tests tests/test_theme.cpp)
|
add_executable(theme_tests tests/test_theme.cpp)
|
||||||
target_link_libraries(theme_tests PRIVATE theme)
|
target_link_libraries(theme_tests PRIVATE theme)
|
||||||
add_test(NAME theme_tests COMMAND theme_tests)
|
add_test(NAME theme_tests COMMAND theme_tests)
|
||||||
@@ -840,6 +861,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/bank_book.cpp
|
src/bank_book.cpp
|
||||||
src/owned_manifest.cpp
|
src/owned_manifest.cpp
|
||||||
src/drag_out_win.cpp
|
src/drag_out_win.cpp
|
||||||
|
src/instrument_drop_win.cpp
|
||||||
src/action_bar.cpp
|
src/action_bar.cpp
|
||||||
src/footer_bar.cpp
|
src/footer_bar.cpp
|
||||||
src/overflow_menu.cpp
|
src/overflow_menu.cpp
|
||||||
@@ -848,7 +870,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/card_meta.cpp
|
src/card_meta.cpp
|
||||||
src/card_drag.cpp
|
src/card_drag.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync)
|
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync)
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
|
||||||
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
|
||||||
|
|||||||
@@ -634,17 +634,31 @@ a clear affordance pointing there.
|
|||||||
without it via a direct reload). **Spike — do not promise the drop-onto-editor path until
|
without it via a direct reload). **Spike — do not promise the drop-onto-editor path until
|
||||||
the relay is proven.**
|
the relay is proven.**
|
||||||
|
|
||||||
- [ ] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child
|
- [x] Editor-window drop target: accept `WM_DROPFILES`/`IDropTarget` on the editor child
|
||||||
HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file
|
HWND (the same SWELL/Win32 surface `bank_panel` owns), extracting the dropped file
|
||||||
path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's.
|
path(s). Windows-only (D5). This is the *acceptance* half; the ingest is the extension's.
|
||||||
|
**Landed:** the editor child window calls `DragAcceptFiles(TRUE)` on attach and handles
|
||||||
|
`WM_DROPFILES` (`reasampler_editor.cpp`). Windows-only (D5).
|
||||||
- [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped
|
- [ ] Cross-artifact ingest relay (the S8-flagged spike): the editor hands the dropped
|
||||||
path + this instance's identity to the extension as a **bank-ingest request** over the
|
path + this instance's identity to the extension as a **bank-ingest request** over the
|
||||||
agreed seam (the instrument never writes the bank). **Prove the relay mechanism before
|
agreed seam (the instrument never writes the bank). **Prove the relay mechanism before
|
||||||
promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark
|
promising it**; if gnarly, fall back to the S8 docked-panel drop path and mark
|
||||||
drop-onto-editor deferred.
|
drop-onto-editor deferred. **SPIKE VERDICT (ps-w12, 2026-07-27): DEGRADED — relay
|
||||||
- [ ] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear
|
deferred.** The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY
|
||||||
|
(resolves only `GetProjExtState`, never `SetProjExtState`); a relay would need (a) a new
|
||||||
|
instrument WRITE seam into ext-state and (b) an extension-side timer poller servicing a
|
||||||
|
drop-ingest inbox key with a claim/clear nonce — the SAME cross-process handshake race the
|
||||||
|
S17 spec rejected for its own alternative (A). Both the read-only-instrument boundary and
|
||||||
|
the new poller are load-bearing design calls, so the relay is deferred to a future wave and
|
||||||
|
surfaced as a decision, not crossed unilaterally. The shipped ingest gesture stays
|
||||||
|
drop-onto-docked-panel (S8). The drop-onto-editor path degrades cleanly (below).
|
||||||
|
- [x] UX degrade path: when the relay is unavailable/unproven, the editor shows a clear
|
||||||
"drop files on the ReaSampler panel to add" affordance rather than silently swallowing the
|
"drop files on the ReaSampler panel to add" affordance rather than silently swallowing the
|
||||||
drop — the shipped ingest gesture stays discoverable either way.
|
drop — the shipped ingest gesture stays discoverable either way. **Landed:** the editor
|
||||||
|
ACCEPTS the drop and flashes a transient banner ("drop files onto the ReaSampler bank panel
|
||||||
|
to add them") that decays over a few sync ticks, plus a persistent affordance line in the
|
||||||
|
empty state ("drop a file onto the ReaSampler bank panel"). No file is ingested; NO timeline
|
||||||
|
item is ever inserted (the hard invariant — the editor only displays guidance).
|
||||||
|
|
||||||
## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor)
|
## S15 — sampling modes: Trigger vs Gate (per-sample play-mode; core + editor)
|
||||||
**Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or
|
**Goal:** Give each played sample a **play mode** — **Gate** (classic held note) or
|
||||||
@@ -1020,22 +1034,41 @@ the editor). S17 is the third integration gesture: drop *onto a track's FX butto
|
|||||||
instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S
|
instantiate a player. Gated on the rest of Phase S; the instrument seam is a Phase S
|
||||||
artifact, not extension-only.
|
artifact, not extension-only.
|
||||||
|
|
||||||
- [ ] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a
|
- [x] Extend the `drag_out` pure module with the third gesture: `decideGesture` (or a
|
||||||
successor) returns `InstrumentDrop` when a drag armed with a single capture is over
|
successor) returns `InstrumentDrop` when a drag armed with a single capture is over
|
||||||
REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER
|
REAPER's UI outside the panel client rect, `OsDrag` only when it has left REAPER
|
||||||
entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state +
|
entirely, `Internal`/`OsDrag`/`None` otherwise unchanged. Pure over (drag state +
|
||||||
pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested —
|
pointer + panel rect + an "over-own-UI" predicate the shell supplies). Unit-tested —
|
||||||
the existing `drag_out` invariants (M11) must not regress.
|
the existing `drag_out` invariants (M11) must not regress. **Landed:** `DragGesture::
|
||||||
- [ ] Shell (extension): hover-track the pointer over REAPER's UI during the drag,
|
InstrumentDrop` + two defaulted `DragState` fields (`singleCapture`, `overReaperUi`, the
|
||||||
|
shell-supplied predicate). Defaults false, so an M11 caller filling only
|
||||||
|
`{dragging, hasArmedSamples}` gets byte-identical M11 behavior — the existing tests are
|
||||||
|
the non-regression proof. **OPEN QUESTION RESOLVED (multi-capture over FX button):
|
||||||
|
REJECT** — only `singleCapture` arms InstrumentDrop; a multi payload over REAPER's UI
|
||||||
|
falls through to `OsDrag` (the natural multi-file drag-out), matching the spec's Tier-0
|
||||||
|
reject-or-first lean toward reject.
|
||||||
|
- [x] Shell (extension): hover-track the pointer over REAPER's UI during the drag,
|
||||||
resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against
|
resolve the hovered track + its FX button (verify the TCP/FX-button hit surface against
|
||||||
the SDK — see must-verify), highlight it as a drop target, and on release drive the
|
the SDK — see must-verify), highlight it as a drop target, and on release drive the
|
||||||
drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path.
|
drop. Extends the `bank_panel` drag hook alongside the M11 `drag_out_win` path.
|
||||||
- [ ] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000",
|
**Landed:** `instrument_drop_win::resolveFxDropTarget` wraps `GetThingFromPoint`
|
||||||
|
(verified present; its info string reports `"fx_chain"`/`"fx_N"` for the FX region and a
|
||||||
|
null-track-empty-info for off-REAPER). **OPEN QUESTION RESOLVED (FX hotspot vs. whole
|
||||||
|
TCP): FX HOTSPOT** — the drop target is the FX region specifically, decided from the SDK's
|
||||||
|
own hit-test string (info prefix `"fx_"`), not a home-grown geometry guess. The
|
||||||
|
bank_panel drag hook now hover-tracks outside the client rect (holding internal-drag
|
||||||
|
capture, NOT the modal OS loop) and only hands to `drag_out_win` (OsDrag) when the pointer
|
||||||
|
has left REAPER entirely.
|
||||||
|
- [x] Shell (extension): on drop, `TrackFX_AddByName(track, "VST3:ReaSampler 9000",
|
||||||
false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned
|
false, /*instantiate*/ negative)` to always add a fresh instance; capture the returned
|
||||||
FX index; then invoke the load-capture seam to point the new instance at the dragged
|
FX index; then invoke the load-capture seam to point the new instance at the dragged
|
||||||
capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
|
capture. Batched into one REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`) so the
|
||||||
whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline).
|
whole gesture is one Ctrl-Z (mirrors the bank-verb undo discipline). **Landed:**
|
||||||
- [ ] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry
|
`instrument_drop_win::performInstrumentDrop` — `TrackFX_AddByName` with the CHANNEL-correct
|
||||||
|
name (`"VST3:" + app_version::vstPluginName()`, so a beta extension drops the beta VST),
|
||||||
|
negative instantiate, then `TrackFX_SetNamedConfigParm(..., "vst_chunk", blob)`, all in one
|
||||||
|
`Undo_BeginBlock2`/`EndBlock2` block. NEVER inserts a timeline item.
|
||||||
|
- [x] **ReaSampler 9000 (Phase S artifact):** add the **load-capture seam** — the entry
|
||||||
point that lets the just-added instance be told which capture to play via **(B)
|
point that lets the just-added instance be told which capture to play via **(B)
|
||||||
component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a
|
component-state injection**: the instrument's `getChunk`/`setChunk` must round-trip a
|
||||||
component-state blob whose byte layout is the **shared cross-artifact contract** (defined
|
component-state blob whose byte layout is the **shared cross-artifact contract** (defined
|
||||||
@@ -1043,9 +1076,27 @@ artifact, not extension-only.
|
|||||||
via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half;
|
via `TrackFX_SetNamedConfigParm(..., "vst_chunk", ...)`. This is the cross-artifact half;
|
||||||
it lands in the instrument, not the extension. **Coordination dependency:** the blob
|
it lands in the instrument, not the extension. **Coordination dependency:** the blob
|
||||||
format must be agreed between extension and instrument before either half is final.
|
format must be agreed between extension and instrument before either half is final.
|
||||||
- [ ] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
|
**Landed (no NEW instrument code needed — the seam already exists):** the instrument's
|
||||||
|
`setState`/`getState` already round-trip the full `ComponentState` via
|
||||||
|
`sample_map::serializeComponentState`/`deserializeComponentState` (S10). The extension side
|
||||||
|
REUSES that exact serializer through the new pure `instrument_drop` module
|
||||||
|
(`buildInstrumentDropChunk` -> `serializeComponentState` -> base64) — the shared-writer
|
||||||
|
requirement is met STRUCTURALLY (one serializer, called from both artifacts), so the blob
|
||||||
|
format cannot drift. Pure round-trip test decodes back through the instrument's OWN reader
|
||||||
|
(`deserializeComponentState`) and asserts the capture is selected. **DAW-VERIFY (the one
|
||||||
|
live unknown):** whether REAPER's `vst_chunk` write-parm expects the plugin's raw
|
||||||
|
IComponent-state bytes as we write them, or wraps them in a REAPER container header — the
|
||||||
|
load-bearing caveat is only confirmable in the DAW; if REAPER wraps, the fix is to match
|
||||||
|
its container, the shared serializer stays.
|
||||||
|
- [x] Tests: gesture disambiguation (inside-panel / over-REAPER-UI / left-REAPER) across
|
||||||
single- and multi-capture payloads; FX-button hit resolution (pure geometry where it
|
single- and multi-capture payloads; FX-button hit resolution (pure geometry where it
|
||||||
can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged.
|
can be factored out); M11 OS drag-out and internal bank-to-bank drag both unchanged.
|
||||||
|
**Landed:** `test_drag_out.cpp` adds the InstrumentDrop cases (single over REAPER UI ->
|
||||||
|
InstrumentDrop; single inside -> Internal; single off-REAPER -> OsDrag; multi over UI ->
|
||||||
|
OsDrag; not-dragging short-circuits) with the M11 cases retained as the non-regression
|
||||||
|
guard; `test_instrument_drop.cpp` is the blob round-trip + base64 codec coverage. FX hit
|
||||||
|
resolution is REAPER-API-bound (`GetThingFromPoint`), so it is DAW-verified in the shell,
|
||||||
|
not pure-tested — noted honestly.
|
||||||
|
|
||||||
## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only
|
## S18 — VST3 channel isolation: a beta ReaSampler 9000 that pairs with the beta extension only
|
||||||
**Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully
|
**Goal:** Extend Phase V's beta/stable channel split (V4 — the *extension* is fully
|
||||||
|
|||||||
+95
-31
@@ -62,6 +62,8 @@
|
|||||||
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
|
#include "footer_bar.h" // pure footer LEFT-group layout: toggle + count + Tail button (L4)
|
||||||
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
||||||
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
|
#include "ingest.h" // ingestDroppedFiles — S8 drop-onto-panel ingest
|
||||||
|
#include "instrument_drop.h" // pure buildInstrumentDropChunk — the vst_chunk blob (S17)
|
||||||
|
#include "instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop shell (S17)
|
||||||
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
||||||
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
||||||
#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5)
|
#include "mode_enable.h" // opposite-mode tag-button enablement predicate (pure, L5)
|
||||||
@@ -321,6 +323,15 @@ struct PanelState {
|
|||||||
CardGesture cardGesture = CardGesture::None;
|
CardGesture cardGesture = CardGesture::None;
|
||||||
int dragTargetSlot = -1;
|
int dragTargetSlot = -1;
|
||||||
|
|
||||||
|
// --- S17 drop-and-load (InstrumentDrop) -----------------------------------
|
||||||
|
// While a SINGLE-capture drag is over REAPER's own UI outside the panel, the drag is an
|
||||||
|
// InstrumentDrop heading for a track's TCP FX button. The shell hover-tracks the FX
|
||||||
|
// hotspot; on release over a valid target it adds a ReaSampler 9000 preloaded with the
|
||||||
|
// dragged capture (no OS drag, no timeline insert). instrumentDropTrack is the last
|
||||||
|
// resolved FX-hotspot track (null when the pointer is not over an FX button) — read on
|
||||||
|
// release. Only set/used on Windows (D5); the M11 OsDrag and internal drag are untouched.
|
||||||
|
MediaTrack* instrumentDropTrack = nullptr;
|
||||||
|
|
||||||
// --- Tail-mode toggle -----------------------------------------------------
|
// --- Tail-mode toggle -----------------------------------------------------
|
||||||
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
|
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
|
||||||
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
||||||
@@ -2917,16 +2928,52 @@ void onMouseMove(int x, int y) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (g_panel.dragging) {
|
if (g_panel.dragging) {
|
||||||
// M11 gesture boundary (invariant #4): while a drag with samples is under way, the
|
// M11 gesture boundary, REFINED by S17. While a drag with samples is under way and the
|
||||||
// moment the pointer LEAVES the panel client area the gesture becomes OS-bound —
|
// pointer is INSIDE the client rect it stays the internal bank-to-bank drag (invariant
|
||||||
// hand the payload to the native OS drag. Inside the client area it stays the
|
// #4, byte-identical). Once it LEAVES the client rect the pure drag_out::decideGesture
|
||||||
// existing internal bank-to-bank drag, byte-identical. The boundary decision is the
|
// splits the outside case three ways: a single-capture drag over REAPER's OWN UI is an
|
||||||
// pure drag_out::decideGesture (drag state + pointer + client rect).
|
// InstrumentDrop (hover-track the FX button, drop on release); a multi-capture drag OR a
|
||||||
|
// pointer that has left REAPER entirely is the unchanged M11 OsDrag; inside stays
|
||||||
|
// Internal. The shell supplies the "over REAPER's UI" predicate via GetThingFromPoint.
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
GetClientRect(g_panel.hwnd, &cr);
|
GetClientRect(g_panel.hwnd, &cr);
|
||||||
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
||||||
const DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
|
||||||
if (decideGesture(x, y, client, st) == DragGesture::OsDrag) {
|
|
||||||
|
DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||||
|
st.singleCapture = (g_panel.dragSampleIds.size() == 1);
|
||||||
|
|
||||||
|
// Resolve the FX drop target only when OUTSIDE the client rect (the S17 middle case can
|
||||||
|
// only arise there) and only for a single-capture payload — the SDK hit-test is skipped
|
||||||
|
// on the common internal-drag path so it costs nothing there. The screen conversion is
|
||||||
|
// Windows-only (D5); resolveFxDropTarget owns the REAPER hit query.
|
||||||
|
FxDropTarget fx;
|
||||||
|
if (!inside && st.singleCapture) {
|
||||||
|
POINT sp{x, y};
|
||||||
|
ClientToScreen(g_panel.hwnd, &sp);
|
||||||
|
fx = resolveFxDropTarget(sp.x, sp.y);
|
||||||
|
st.overReaperUi = fx.overReaperUi;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DragGesture gesture = decideGesture(x, y, client, st);
|
||||||
|
|
||||||
|
if (gesture == DragGesture::InstrumentDrop) {
|
||||||
|
// Track the FX hotspot for the release; the highlight is REAPER's own FX-button
|
||||||
|
// hover feedback under the pointer (the drop is driven on button-up). We keep the
|
||||||
|
// internal-drag capture alive so we keep receiving moves (unlike OsDrag, this does
|
||||||
|
// NOT hand off to a modal OS loop). Clear any internal drop-target highlight so the
|
||||||
|
// panel does not also paint a bank-drop cue while the drag is out over a track.
|
||||||
|
g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
|
||||||
|
g_panel.dropKind = DropKind::None;
|
||||||
|
g_panel.dropBankId.clear();
|
||||||
|
invalidatePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target.
|
||||||
|
g_panel.instrumentDropTrack = nullptr;
|
||||||
|
|
||||||
|
if (gesture == DragGesture::OsDrag) {
|
||||||
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
|
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
|
||||||
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
|
// drag state (the resolver reads dragSourceBankId / dragSampleIds).
|
||||||
const std::vector<std::string> paths = resolveDragPathsForOs();
|
const std::vector<std::string> paths = resolveDragPathsForOs();
|
||||||
@@ -2995,31 +3042,45 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId,
|
|||||||
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
|
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
|
||||||
void onLBtnUp(int x, int y) {
|
void onLBtnUp(int x, int y) {
|
||||||
if (g_panel.dragging) {
|
if (g_panel.dragging) {
|
||||||
updateDropTarget(x, y);
|
// S17 drop-and-load: a release while hover-tracking a valid FX hotspot instantiates a
|
||||||
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
|
// ReaSampler 9000 on that track preloaded with the dragged capture — NOT a bank move,
|
||||||
const CardGesture g = g_panel.cardGesture;
|
// NOT an OS drag, NEVER a timeline insert. Takes priority over the L7 in-grid / cross-bank
|
||||||
|
// drop (the pointer is out over a track, not over a bank region). Single-capture only (the
|
||||||
|
// gesture never armed for a multi payload), so dragSampleIds.front() is the capture.
|
||||||
|
if (g_panel.instrumentDropTrack && g_panel.dragSampleIds.size() == 1) {
|
||||||
|
const std::string sampleId = g_panel.dragSampleIds.front();
|
||||||
|
const std::string chunk = buildInstrumentDropChunk(sampleId);
|
||||||
|
performInstrumentDrop(g_panel.instrumentDropTrack, chunk);
|
||||||
|
// Read-only over the bank + arrange: the ONLY mutations are the new FX instance +
|
||||||
|
// its state (both undoable in performInstrumentDrop). No book change, no ext-state,
|
||||||
|
// no dirty-mark here.
|
||||||
|
} else {
|
||||||
|
updateDropTarget(x, y);
|
||||||
|
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
|
||||||
|
const CardGesture g = g_panel.cardGesture;
|
||||||
|
|
||||||
if (g == CardGesture::Reorder) {
|
if (g == CardGesture::Reorder) {
|
||||||
doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId,
|
doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId,
|
||||||
g_panel.dragTargetSlot);
|
g_panel.dragTargetSlot);
|
||||||
} else if (g == CardGesture::Replace) {
|
} else if (g == CardGesture::Replace) {
|
||||||
// Replace targets the OCCUPANT of the target slot with the single grabbed card.
|
// Replace targets the OCCUPANT of the target slot with the single grabbed card.
|
||||||
const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
|
const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
GetClientRect(g_panel.hwnd, &cr);
|
GetClientRect(g_panel.hwnd, &cr);
|
||||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||||
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
||||||
const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
|
const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
|
||||||
const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
|
const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
|
||||||
// Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
|
// Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
|
||||||
if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
|
if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
|
||||||
doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
|
doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
|
||||||
} else if (g == CardGesture::Move || g == CardGesture::Copy) {
|
} else if (g == CardGesture::Move || g == CardGesture::Copy) {
|
||||||
const std::string destId = dropTargetBankId();
|
const std::string destId = dropTargetBankId();
|
||||||
if (!destId.empty() && destId != g_panel.dragSourceBankId &&
|
if (!destId.empty() && destId != g_panel.dragSourceBankId &&
|
||||||
!g_panel.dragSampleIds.empty()) {
|
!g_panel.dragSampleIds.empty()) {
|
||||||
transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
|
transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
|
||||||
/*copy=*/g == CardGesture::Copy);
|
/*copy=*/g == CardGesture::Copy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
|
// CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
|
||||||
@@ -3041,6 +3102,7 @@ void onLBtnUp(int x, int y) {
|
|||||||
g_panel.cardGesture = CardGesture::None;
|
g_panel.cardGesture = CardGesture::None;
|
||||||
g_panel.dragTargetSlot = -1;
|
g_panel.dragTargetSlot = -1;
|
||||||
g_panel.dragPrimaryId.clear();
|
g_panel.dragPrimaryId.clear();
|
||||||
|
g_panel.instrumentDropTrack = nullptr; // S17: clear the FX hotspot after the release
|
||||||
invalidatePanel();
|
invalidatePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3169,6 +3231,7 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
|||||||
g_panel.cardGesture = CardGesture::None;
|
g_panel.cardGesture = CardGesture::None;
|
||||||
g_panel.dragTargetSlot = -1;
|
g_panel.dragTargetSlot = -1;
|
||||||
g_panel.dragPrimaryId.clear();
|
g_panel.dragPrimaryId.clear();
|
||||||
|
g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on teardown
|
||||||
g_panel.hovered = Hover{};
|
g_panel.hovered = Hover{};
|
||||||
g_panel.tooltipShown = false;
|
g_panel.tooltipShown = false;
|
||||||
g_panel.hwnd = nullptr;
|
g_panel.hwnd = nullptr;
|
||||||
@@ -3226,6 +3289,7 @@ void closePanel() {
|
|||||||
stopAudition();
|
stopAudition();
|
||||||
g_panel.selection = Selection{};
|
g_panel.selection = Selection{};
|
||||||
g_panel.dragArmed = g_panel.dragging = false;
|
g_panel.dragArmed = g_panel.dragging = false;
|
||||||
|
g_panel.instrumentDropTrack = nullptr; // S17: drop the FX hotspot on close
|
||||||
unregisterAccel();
|
unregisterAccel();
|
||||||
if (g_panel.hwnd) {
|
if (g_panel.hwnd) {
|
||||||
DockWindowRemove(g_panel.hwnd);
|
DockWindowRemove(g_panel.hwnd);
|
||||||
|
|||||||
+7
-1
@@ -19,7 +19,13 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
|
|||||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||||
const DragState& state) {
|
const DragState& state) {
|
||||||
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
|
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
|
||||||
return insideClient(px, py, client) ? DragGesture::Internal : DragGesture::OsDrag;
|
if (insideClient(px, py, client)) return DragGesture::Internal;
|
||||||
|
// Outside the client rect (M11 boundary), refined by S17: a SINGLE-capture drag that is
|
||||||
|
// still over REAPER's own UI is an instrument drop (heading for a track's FX button);
|
||||||
|
// anything else (a multi-capture payload, or the pointer off REAPER entirely) is the
|
||||||
|
// unchanged M11 OS drag-out.
|
||||||
|
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
|
||||||
|
return DragGesture::OsDrag;
|
||||||
}
|
}
|
||||||
|
|
||||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||||
|
|||||||
+31
-9
@@ -52,28 +52,50 @@ struct PanelClientRect {
|
|||||||
// whether a drag is currently active (threshold crossed) and whether the armed payload
|
// whether a drag is currently active (threshold crossed) and whether the armed payload
|
||||||
// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag
|
// carries at least one sample. (Pre-threshold "armed but not yet dragging" is NOT a drag
|
||||||
// for this decision — the shell only asks once a drag is under way.)
|
// for this decision — the shell only asks once a drag is under way.)
|
||||||
|
//
|
||||||
|
// S17 (drop-and-load) adds two inputs that refine the OUTSIDE-the-panel decision without
|
||||||
|
// touching the INSIDE decision (the internal bank-to-bank drag stays byte-identical):
|
||||||
|
// * singleCapture — the payload holds EXACTLY ONE sample id. Only a single-capture drag
|
||||||
|
// arms the InstrumentDrop gesture (per the S17 open-question lean: a multi-capture drag
|
||||||
|
// over an FX button is NOT an instrument drop — it falls through to OsDrag, the natural
|
||||||
|
// multi-file drag-out to Explorer/another DAW). REJECT, not load-first: the whole gesture
|
||||||
|
// is "make ONE capture a playable instrument", so a multi payload is out of contract here.
|
||||||
|
// * overReaperUi — a SHELL-SUPPLIED predicate: true when the pointer, though outside the
|
||||||
|
// panel client rect, is still over REAPER's OWN window/UI (the shell owns the REAPER
|
||||||
|
// hit query, e.g. GetThingFromPoint; the pure layer owns only the set/boundary algebra).
|
||||||
|
// Both default false, so an M11-era caller that fills only {dragging, hasArmedSamples} gets
|
||||||
|
// EXACTLY the M11 behavior: outside the client rect with overReaperUi=false -> OsDrag.
|
||||||
struct DragState {
|
struct DragState {
|
||||||
bool dragging = false; // threshold crossed; a drag is in progress
|
bool dragging = false; // threshold crossed; a drag is in progress
|
||||||
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id
|
bool hasArmedSamples = false; // the drag payload holds >= 1 sample id
|
||||||
|
bool singleCapture = false; // S17: payload holds EXACTLY one sample (arms InstrumentDrop)
|
||||||
|
bool overReaperUi = false; // S17: pointer is over REAPER's own UI (shell-supplied)
|
||||||
};
|
};
|
||||||
|
|
||||||
// What the shell should do with the drag given the current pointer position.
|
// What the shell should do with the drag given the current pointer position.
|
||||||
enum class DragGesture {
|
enum class DragGesture {
|
||||||
None, // no drag under way, or an empty payload — do nothing
|
None, // no drag under way, or an empty payload — do nothing
|
||||||
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag
|
Internal, // dragging inside the panel — the existing bank-to-bank move/copy drag
|
||||||
OsDrag, // dragging with samples, pointer left the client area — hand off to the OS
|
InstrumentDrop, // S17: single-capture drag left the panel but is over REAPER's UI —
|
||||||
|
// the shell hover-tracks the TCP FX button and, on release, adds a
|
||||||
|
// ReaSampler 9000 instance preloaded with the dragged capture.
|
||||||
|
OsDrag, // dragging with samples, pointer left REAPER entirely — hand off to the OS
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`.
|
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`.
|
||||||
// * Not dragging (or no armed samples): None — the shell ignores the move.
|
// * Not dragging (or no armed samples): None — the shell ignores the move.
|
||||||
// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged
|
// * Dragging with samples, pointer INSIDE the client rect: Internal — unchanged
|
||||||
// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical).
|
// bank-to-bank behavior (invariant #4: the internal drag stays byte-identical).
|
||||||
// * Dragging with samples, pointer OUTSIDE the client rect: OsDrag — the samples are
|
// * Dragging OUTSIDE the client rect, SINGLE capture, over REAPER's UI: InstrumentDrop —
|
||||||
// leaving the panel; the shell initiates the native OS drag with the resolved paths.
|
// the drag is heading for a track's FX button (S17); the shell hover-tracks + highlights.
|
||||||
// The boundary is the client rect edge: the internal drag never targets outside it, so
|
// * Dragging OUTSIDE the client rect otherwise (multi-capture, OR the pointer has left
|
||||||
// crossing it is an unambiguous, discoverable OS-drag trigger. Re-entry is the shell's
|
// REAPER entirely): OsDrag — the samples are leaving to the OS; the shell initiates the
|
||||||
// concern (the OS drag loop is modal once begun); this function reports OsDrag purely from
|
// native OS drag with the resolved paths.
|
||||||
// position, so a shell that has already handed off simply will not ask again.
|
// The INSIDE decision is untouched (M11 internal drag is byte-identical). The M11 boundary
|
||||||
|
// (left the client rect -> OsDrag) is REFINED, not replaced: leaving the rect now asks
|
||||||
|
// "single-capture and over REAPER's UI -> InstrumentDrop, else -> OsDrag" — so the M11
|
||||||
|
// OS-drag-out (multi payload, or pointer off REAPER) keeps its exact behavior. Position-only
|
||||||
|
// + state-only (no hidden state), so re-entry back inside returns Internal.
|
||||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||||
const DragState& state);
|
const DragState& state);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// instrument_drop — pure implementation. See instrument_drop.h.
|
||||||
|
// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer.
|
||||||
|
|
||||||
|
#include "instrument_drop.h"
|
||||||
|
|
||||||
|
#include "vst/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr char kB64Alphabet[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
// -1 = not a base64 char; index by unsigned byte. Built once.
|
||||||
|
int b64Value(unsigned char c) {
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||||
|
if (c == '+') return 62;
|
||||||
|
if (c == '/') return 63;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId) {
|
||||||
|
// The ONE fact the drop carries: this capture is the instance's selection. Everything
|
||||||
|
// else stays at the fresh-instance defaults (no zones, mono, generation 0) — the same
|
||||||
|
// ComponentState a browser click would produce. serializeComponentState is the
|
||||||
|
// instrument's own writer (the single source of truth for the byte layout), so this is
|
||||||
|
// NOT a parallel encoder — it IS the instrument's encoder.
|
||||||
|
ComponentState cs;
|
||||||
|
cs.selectionId = sampleId;
|
||||||
|
return serializeComponentState(cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string buildInstrumentDropChunk(const std::string& sampleId) {
|
||||||
|
return encodeBase64(instrumentDropStateBytes(sampleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string encodeBase64(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(((bytes.size() + 2) / 3) * 4);
|
||||||
|
std::size_t i = 0;
|
||||||
|
const std::size_t n = bytes.size();
|
||||||
|
while (i + 3 <= n) {
|
||||||
|
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
|
||||||
|
static_cast<std::uint32_t>(bytes[i + 2]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[triple & 0x3F]);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
const std::size_t rem = n - i;
|
||||||
|
if (rem == 1) {
|
||||||
|
const std::uint32_t triple = static_cast<std::uint32_t>(bytes[i]) << 16;
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||||
|
out.push_back('=');
|
||||||
|
out.push_back('=');
|
||||||
|
} else if (rem == 2) {
|
||||||
|
const std::uint32_t triple = (static_cast<std::uint32_t>(bytes[i]) << 16) |
|
||||||
|
(static_cast<std::uint32_t>(bytes[i + 1]) << 8);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 18) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 12) & 0x3F]);
|
||||||
|
out.push_back(kB64Alphabet[(triple >> 6) & 0x3F]);
|
||||||
|
out.push_back('=');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> decodeBase64(const std::string& b64) {
|
||||||
|
std::vector<std::uint8_t> out;
|
||||||
|
if (b64.size() % 4 != 0) return out; // malformed length -> empty (never throws)
|
||||||
|
out.reserve((b64.size() / 4) * 3);
|
||||||
|
for (std::size_t i = 0; i < b64.size(); i += 4) {
|
||||||
|
const char c0 = b64[i], c1 = b64[i + 1], c2 = b64[i + 2], c3 = b64[i + 3];
|
||||||
|
const int v0 = b64Value(static_cast<unsigned char>(c0));
|
||||||
|
const int v1 = b64Value(static_cast<unsigned char>(c1));
|
||||||
|
if (v0 < 0 || v1 < 0) return {}; // illegal char in a non-pad position -> empty
|
||||||
|
// Padding is only legal in the last two positions of the last quad.
|
||||||
|
const bool pad2 = (c2 == '=');
|
||||||
|
const bool pad3 = (c3 == '=');
|
||||||
|
if ((pad2 || pad3) && i + 4 != b64.size()) return {}; // pad before the final quad
|
||||||
|
if (pad2 && !pad3) return {}; // "=X" is malformed
|
||||||
|
std::uint32_t triple = (static_cast<std::uint32_t>(v0) << 18) |
|
||||||
|
(static_cast<std::uint32_t>(v1) << 12);
|
||||||
|
out.push_back(static_cast<std::uint8_t>((triple >> 16) & 0xFF));
|
||||||
|
if (!pad2) {
|
||||||
|
const int v2 = b64Value(static_cast<unsigned char>(c2));
|
||||||
|
if (v2 < 0) return {};
|
||||||
|
triple |= static_cast<std::uint32_t>(v2) << 6;
|
||||||
|
out.push_back(static_cast<std::uint8_t>((triple >> 8) & 0xFF));
|
||||||
|
if (!pad3) {
|
||||||
|
const int v3 = b64Value(static_cast<unsigned char>(c3));
|
||||||
|
if (v3 < 0) return {};
|
||||||
|
triple |= static_cast<std::uint32_t>(v3);
|
||||||
|
out.push_back(static_cast<std::uint8_t>(triple & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
// instrument_drop — the PURE blob-construction core of S17 drop-and-load.
|
||||||
|
//
|
||||||
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK,
|
||||||
|
// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses). Unit-tested
|
||||||
|
// outside the DAW — the same "small pure builder + round-trip proof" pattern as
|
||||||
|
// assignment_request / provenance.
|
||||||
|
//
|
||||||
|
// -- What it is (the S17 seam, extension side) --------------------------------
|
||||||
|
//
|
||||||
|
// S17 drops a bank capture onto a track's FX button, which instantiates ReaSampler 9000 on
|
||||||
|
// that track ALREADY PLAYING that capture. The SETTLED mechanism (PLAN.md §S17, mechanism
|
||||||
|
// (B) — VST3 component-state injection) is: after TrackFX_AddByName creates the instance, the
|
||||||
|
// extension writes the instance's component state directly via
|
||||||
|
// TrackFX_SetNamedConfigParm(track, fx, "vst_chunk", <base64 blob>)
|
||||||
|
// with the dragged capture PRE-SELECTED.
|
||||||
|
//
|
||||||
|
// LOAD-BEARING CAVEAT (PLAN.md §S17): "vst_chunk" is the plugin's OWN base64-encoded
|
||||||
|
// serialized chunk — the exact bytes ReaSampler 9000's setState/getState round-trips — NOT a
|
||||||
|
// neutral representation REAPER re-marshals. So the extension must construct EXACTLY the
|
||||||
|
// instrument's own state-blob bytes. This module does that WITHOUT hand-rolling a parallel
|
||||||
|
// byte writer: it calls the instrument's OWN serializer, sample_map::serializeComponentState
|
||||||
|
// (the single source of truth for the byte layout — the same function the processor's
|
||||||
|
// getState calls), then base64-encodes the result. The shared-writer requirement (both
|
||||||
|
// artifacts live in this repo → reuse the exact same code) is satisfied structurally: if the
|
||||||
|
// instrument's format changes, this module changes with it because it CALLS it.
|
||||||
|
//
|
||||||
|
// The base64 encoding is what REAPER's vst_chunk write-parm documents it accepts (see
|
||||||
|
// reaper_plugin_functions.h: "vst_chunk[_program] : base64-encoded VST-specific chunk").
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// Build the base64 blob the extension writes to TrackFX_SetNamedConfigParm(..., "vst_chunk").
|
||||||
|
// `sampleId` is the dragged capture's stable bank id — the ONLY thing the drop pre-selects.
|
||||||
|
// The resulting ComponentState is the instrument's default face with just this one capture
|
||||||
|
// picked: {selectionId = sampleId, no zones, mono, lastConsumedAssignGeneration = 0} — exactly
|
||||||
|
// what a fresh instance would hold after the user clicked that capture in the browser. The
|
||||||
|
// keymap builds under the product defaults (Gate + Preserve) from the bank's own S2 intrinsics,
|
||||||
|
// so the sample plays MIDI-triggered immediately (the S17 "loaded, selected, playable" verify).
|
||||||
|
//
|
||||||
|
// An EMPTY sampleId yields the empty-state blob ({"", no zones}) — a drop of nothing selects
|
||||||
|
// nothing (the S10 silent empty state); the shell guards against this upstream, but the pure
|
||||||
|
// contract is defined.
|
||||||
|
//
|
||||||
|
// Deterministic: the same sampleId always yields the same blob (base64 of the same bytes).
|
||||||
|
std::string buildInstrumentDropChunk(const std::string& sampleId);
|
||||||
|
|
||||||
|
// The raw (pre-base64) component-state bytes — exposed so the round-trip test can decode them
|
||||||
|
// back through the instrument's OWN reader (sample_map::deserializeComponentState) and assert
|
||||||
|
// the capture is selected, proving buildInstrumentDropChunk feeds the instrument exactly what
|
||||||
|
// its setState expects. Not called by the shell (which uses the base64 form).
|
||||||
|
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
|
||||||
|
|
||||||
|
// Standard base64 encode/decode (RFC 4648, '+' '/' alphabet, '=' padding). Exposed so the
|
||||||
|
// round-trip test can decode buildInstrumentDropChunk's output. decodeBase64 returns the
|
||||||
|
// decoded bytes; on malformed input (bad length / illegal char) it returns an EMPTY vector
|
||||||
|
// (never throws) — the test asserts a clean decode, and the shell never decodes.
|
||||||
|
std::string encodeBase64(const std::vector<std::uint8_t>& bytes);
|
||||||
|
std::vector<std::uint8_t> decodeBase64(const std::string& b64);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// instrument_drop_win — the REAPER shell for S17 drop-and-load. See instrument_drop_win.h.
|
||||||
|
//
|
||||||
|
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h WITHOUT
|
||||||
|
// REAPERAPI_IMPLEMENT (main.cpp owns the pointers; here they are extern via the WANT list).
|
||||||
|
|
||||||
|
#include "instrument_drop_win.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
|
||||||
|
|
||||||
|
#include "reaper_plugin.h"
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_GetThingFromPoint
|
||||||
|
#define REAPERAPI_WANT_TrackFX_AddByName
|
||||||
|
#define REAPERAPI_WANT_TrackFX_Delete
|
||||||
|
#define REAPERAPI_WANT_TrackFX_SetNamedConfigParm
|
||||||
|
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||||
|
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// GetThingFromPoint's info string prefixes (verified against reaper_plugin_functions.h:
|
||||||
|
// "Updates infoOut with information such as 'arrange', 'fx_chain', 'fx_0' ... If a track
|
||||||
|
// panel is hit, string will begin with 'tcp' or 'mcp' or 'tcp.mute' etc"). The FX region
|
||||||
|
// reports "fx_chain" (the FX list area) or "fx_N" (a specific FX button). We treat either
|
||||||
|
// as the FX hotspot — the S17 drop target.
|
||||||
|
bool infoNamesFxHotspot(const char* info) {
|
||||||
|
return std::strncmp(info, "fx_", 3) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
|
||||||
|
FxDropTarget out;
|
||||||
|
char info[256] = {0};
|
||||||
|
// GetThingFromPoint returns the track under the point (may be null for a non-track thing)
|
||||||
|
// and fills `info` with what was hit. A non-empty info OR a non-null track means the point
|
||||||
|
// is over REAPER's own UI; a null track with an empty info means the pointer has left
|
||||||
|
// REAPER entirely (over another app / the desktop) — the OsDrag boundary.
|
||||||
|
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||||
|
out.track = track;
|
||||||
|
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
|
||||||
|
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64) {
|
||||||
|
if (!track || chunkBase64.empty()) return false;
|
||||||
|
|
||||||
|
// The CHANNEL-correct FX name: "VST3:ReaSampler 9000" on stable, "VST3:ReaSampler 9000
|
||||||
|
// beta" on beta. Sourcing it from app_version::vstPluginName() (the same accessor the VST
|
||||||
|
// factory display name derives from) keeps the pairing invariant intact — a beta extension
|
||||||
|
// drops the beta VST, a stable extension the stable VST — with no literal to drift.
|
||||||
|
const std::string fxName = "VST3:" + vstPluginName();
|
||||||
|
|
||||||
|
// One undo point for the whole gesture (mirrors the bank-verb undo discipline). Both the
|
||||||
|
// FX add and the state write are REAPER-undoable, so Ctrl-Z removes the instance cleanly.
|
||||||
|
Undo_BeginBlock2(nullptr);
|
||||||
|
|
||||||
|
// Negative `instantiate` => always create a NEW instance (verified in the header). recFX
|
||||||
|
// = false: a normal track FX chain instance, not a record/monitoring FX.
|
||||||
|
const int fxIndex = TrackFX_AddByName(track, fxName.c_str(), /*recFX=*/false,
|
||||||
|
/*instantiate=*/-1);
|
||||||
|
bool ok = false;
|
||||||
|
if (fxIndex >= 0) {
|
||||||
|
// Inject the instrument's OWN component-state blob (the dragged capture pre-selected)
|
||||||
|
// via the documented vst_chunk write-parm. The blob was built by the shared writer
|
||||||
|
// (instrument_drop::buildInstrumentDropChunk -> sample_map::serializeComponentState),
|
||||||
|
// so these bytes are exactly what ReaSampler 9000's setState accepts.
|
||||||
|
ok = TrackFX_SetNamedConfigParm(track, fxIndex, "vst_chunk", chunkBase64.c_str());
|
||||||
|
if (!ok) {
|
||||||
|
// All-or-nothing: if the chunk write fails, remove the empty FX instance we just
|
||||||
|
// added so the track is left exactly as it was. TrackFX_Delete signature (verified
|
||||||
|
// in reaper_plugin_functions.h:7236): bool TrackFX_Delete(MediaTrack*, int fx).
|
||||||
|
TrackFX_Delete(track, fxIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The undo label reflects the placement-of-the-player framing (not a capture, not an insert).
|
||||||
|
Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
// instrument_drop_win — the REAPER-facing shell half of S17 drop-and-load. The pure gesture
|
||||||
|
// decision lives in drag_out (DragGesture::InstrumentDrop) and the pure blob construction in
|
||||||
|
// instrument_drop; THIS is the platform shell that (a) resolves a screen point to a track +
|
||||||
|
// its TCP FX-button hotspot via REAPER's hit-test API, and (b) on release adds a ReaSampler
|
||||||
|
// 9000 instance to that track and injects the dragged capture as its component state.
|
||||||
|
//
|
||||||
|
// Compiled into the reaper_reasampler MODULE. REAPER-facing (GetThingFromPoint, TrackFX_*,
|
||||||
|
// Undo_*), so DAW-verified, not unit-tested; the pure decision + blob it drives are CTest'd.
|
||||||
|
//
|
||||||
|
// LOAD-BEARING (CONTEXT.md §Drop-and-load): this is an EXPLICIT user placement-of-the-player
|
||||||
|
// gesture — it adds a READER of the bank on a track and points it at one already-captured
|
||||||
|
// sample. It NEVER captures, NEVER writes the bank, and NEVER inserts a timeline item. The
|
||||||
|
// only writes are: a new FX instance on the target track + that instance's own component
|
||||||
|
// state — both REAPER-undoable, wrapped in one undo block so the whole gesture is one Ctrl-Z.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Opaque REAPER track handle at the boundary so includers don't need the SDK. The SDK
|
||||||
|
// declares it as a class (reaper_plugin.h) — match that spelling so the mangled name agrees.
|
||||||
|
class MediaTrack;
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// The result of hit-testing a screen point during a live InstrumentDrop drag.
|
||||||
|
struct FxDropTarget {
|
||||||
|
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
|
||||||
|
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
|
||||||
|
bool overFxHotspot = false; // specifically over this track's TCP FX-button/-chain region
|
||||||
|
|
||||||
|
// A valid drop target: a resolved track whose FX hotspot is under the pointer.
|
||||||
|
bool valid() const { return track != nullptr && overFxHotspot; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hit-test a screen point (REAPER screen coords) to an FX drop target. Wraps
|
||||||
|
// GetThingFromPoint, whose info string tells us what was hit ("tcp"/"mcp" for a track panel,
|
||||||
|
// "fx_chain"/"fx_N" for the FX area/button). `overReaperUi` is the shell-supplied predicate
|
||||||
|
// the pure drag_out::decideGesture consumes (true when the point is over REAPER's own UI —
|
||||||
|
// i.e. GetThingFromPoint returned a track OR a recognizable non-track thing, false when the
|
||||||
|
// pointer has left REAPER entirely). `overFxHotspot` is true when the info string names the
|
||||||
|
// FX region specifically — the S17 "FX-button hotspot vs. whole TCP" question is resolved to
|
||||||
|
// the FX hotspot (the discoverable, unambiguous target), decided here from the SDK's own
|
||||||
|
// hit-test string rather than a home-grown geometry guess.
|
||||||
|
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
|
||||||
|
|
||||||
|
// Perform the drop on `track`: add a fresh ReaSampler 9000 instance and inject `chunkBase64`
|
||||||
|
// (the instrument_drop::buildInstrumentDropChunk output) as its component state so it plays
|
||||||
|
// the dragged capture. `chunkBase64` is the base64 vst_chunk. Wraps the add + inject in one
|
||||||
|
// REAPER undo block (mirrors the bank-verb undo discipline). Returns true on success (the FX
|
||||||
|
// was added and the chunk written), false on any failure. All-or-nothing: if the chunk write
|
||||||
|
// fails after a successful add, the freshly-added FX instance is removed via TrackFX_Delete
|
||||||
|
// before returning false, leaving the track exactly as it was (no orphaned empty-state FX).
|
||||||
|
// NEVER inserts a timeline item; the ONLY mutations are the FX instance + its state, both
|
||||||
|
// undoable.
|
||||||
|
bool performInstrumentDrop(MediaTrack* track, const std::string& chunkBase64);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||||
|
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept
|
||||||
|
|
||||||
#include "wdltypes.h"
|
#include "wdltypes.h"
|
||||||
#include "lice/lice.h"
|
#include "lice/lice.h"
|
||||||
@@ -218,6 +219,12 @@ void ReaSamplerEditor::onSyncTimer() {
|
|||||||
refreshFromBank();
|
refreshFromBank();
|
||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
|
||||||
|
if (dropHintTicks_ > 0) {
|
||||||
|
--dropHintTicks_;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif // _WIN32
|
#endif // _WIN32
|
||||||
|
|
||||||
@@ -522,6 +529,10 @@ void ReaSamplerEditor::attachedToParent() {
|
|||||||
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
|
||||||
if (childHwnd_) {
|
if (childHwnd_) {
|
||||||
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
|
||||||
|
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
|
||||||
|
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
|
||||||
|
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
|
||||||
|
DragAcceptFiles(childHwnd_, TRUE);
|
||||||
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
|
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
|
||||||
// lifetime — created here, killed in removedFromParent — so an instance whose editor
|
// lifetime — created here, killed in removedFromParent — so an instance whose editor
|
||||||
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
|
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
|
||||||
@@ -697,6 +708,19 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
|||||||
paintBrowser(&bmp, w, h);
|
paintBrowser(&bmp, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
|
||||||
|
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
|
||||||
|
// LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_).
|
||||||
|
if (dropHintTicks_ > 0) {
|
||||||
|
const int bannerH = (std::min)(kTitleHeight + 8, h);
|
||||||
|
Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH};
|
||||||
|
LICE_FillRect(&bmp, banner.left, banner.top, banner.width(), banner.height(),
|
||||||
|
kColTabActiveBg, 1.0f, 0);
|
||||||
|
drawTextCentered(&bmp, banner,
|
||||||
|
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||||
|
kRgbText);
|
||||||
|
}
|
||||||
|
|
||||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +730,16 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
|||||||
const char* msg = samples_.empty()
|
const char* msg = samples_.empty()
|
||||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||||
: "No captures in this bank filter. Choose another bank tab above.";
|
: "No captures in this bank filter. Choose another bank tab above.";
|
||||||
drawTextCentered(bmp, area, msg, kRgbDim);
|
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
|
||||||
|
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
|
||||||
|
// discoverable here regardless of whether a drop ever lands on THIS window.
|
||||||
|
Rect primary{area.left, area.top, area.right, area.top + area.height() / 2};
|
||||||
|
Rect hint{area.left, primary.bottom, area.right, area.bottom};
|
||||||
|
drawTextCentered(bmp, primary, msg, kRgbDim);
|
||||||
|
drawTextCentered(bmp,
|
||||||
|
hint,
|
||||||
|
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
|
||||||
|
kRgbDim);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
|
||||||
@@ -1559,6 +1592,20 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
|||||||
invalidate();
|
invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||||
|
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
|
||||||
|
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
|
||||||
|
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
|
||||||
|
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
|
||||||
|
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
|
||||||
|
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
|
||||||
|
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||||
|
dropHintTicks_ = 6;
|
||||||
|
#ifdef _WIN32
|
||||||
|
invalidate();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||||
LPARAM lParam) {
|
LPARAM lParam) {
|
||||||
auto* self =
|
auto* self =
|
||||||
@@ -1612,6 +1659,16 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
|||||||
self->invalidate();
|
self->invalidate();
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
case WM_DROPFILES: {
|
||||||
|
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
|
||||||
|
// read/ingest the paths (the instrument never ingests — the relay to the extension is
|
||||||
|
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
|
||||||
|
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
||||||
|
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||||||
|
DragFinish(drop);
|
||||||
|
if (self) self->onFilesDropped(static_cast<int>(count));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
if (self && wParam == kSyncTimerId) self->onSyncTimer();
|
if (self && wParam == kSyncTimerId) self->onSyncTimer();
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ private:
|
|||||||
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
void onMouseWheel(int delta); // S12 browser scroll (wheel)
|
||||||
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
void onSearchChar(unsigned int ch); // S12 type-to-filter search keystroke
|
||||||
|
|
||||||
|
// S13 (relay degraded): an OS file drop landed on the editor window. We do NOT ingest (the
|
||||||
|
// instrument is a read-only bank consumer and the relay is unshipped) — we flash the "drop
|
||||||
|
// on the ReaSampler panel to add" affordance so the drop is never silently swallowed and the
|
||||||
|
// shipped ingest gesture stays discoverable. `droppedCount` is how many files were dropped
|
||||||
|
// (drawn into the banner). NEVER inserts a timeline item / never touches the bank.
|
||||||
|
void onFilesDropped(int droppedCount);
|
||||||
|
|
||||||
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
// The S9/S8 change-detection tick (WM_TIMER on the child window — the UI thread, NEVER the
|
||||||
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
// audio thread). Polls the processor's bank-sync (generation change -> hands-free reload;
|
||||||
// a new assignment request -> apply as this instance's selection) and, when anything
|
// a new assignment request -> apply as this instance's selection) and, when anything
|
||||||
@@ -212,6 +219,17 @@ private:
|
|||||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||||
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none
|
||||||
|
|
||||||
|
// --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ----
|
||||||
|
// S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the
|
||||||
|
// instrument's REAPER bridge is deliberately READ-ONLY (it never writes the bank / ext
|
||||||
|
// state), so an editor drop cannot relay a bank-ingest request without a new write seam +
|
||||||
|
// an extension-side poller (surfaced as a decision, not crossed here). The DEGRADE path per
|
||||||
|
// the spec: the editor ACCEPTS the drop (WM_DROPFILES) and, rather than silently swallowing
|
||||||
|
// it, flashes a clear affordance pointing at the shipped ingest gesture (drop onto the
|
||||||
|
// docked ReaSampler panel). When > 0, the affordance banner is shown; each sync tick decays
|
||||||
|
// it so it auto-dismisses. No file is ingested, no timeline item is ever inserted.
|
||||||
|
int dropHintTicks_ = 0; // remaining sync ticks to show the drop affordance
|
||||||
|
|
||||||
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
// --- S12 browser scroll + search (transient UI state, never persisted) --------
|
||||||
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
int scrollOffset_ = 0; // vertical px offset into the card grid (clamped)
|
||||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||||
|
|||||||
@@ -84,6 +84,54 @@ static void testOffsetPanelRect() {
|
|||||||
CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin
|
CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) ---------------
|
||||||
|
//
|
||||||
|
// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true},
|
||||||
|
// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client
|
||||||
|
// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression
|
||||||
|
// proof; these add the new middle case.
|
||||||
|
|
||||||
|
// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an
|
||||||
|
// instrument drop (heading for a track's FX button), NOT an OS drag.
|
||||||
|
static void testSingleCaptureOverReaperUiIsInstrumentDrop() {
|
||||||
|
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true,
|
||||||
|
/*singleCapture=*/true, /*overReaperUi=*/true};
|
||||||
|
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop); // right of panel
|
||||||
|
CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop); // left of panel
|
||||||
|
CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop); // below
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the
|
||||||
|
// client rect is still the unchanged Internal bank-to-bank drag (invariant #4).
|
||||||
|
static void testSingleCaptureInsidePanelStaysInternal() {
|
||||||
|
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true};
|
||||||
|
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to
|
||||||
|
// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary
|
||||||
|
// refinement's other half: leaving the client rect no longer immediately means OS-bound.
|
||||||
|
static void testSingleCaptureOffReaperIsOsDrag() {
|
||||||
|
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false};
|
||||||
|
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question
|
||||||
|
// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while
|
||||||
|
// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload.
|
||||||
|
static void testMultiCaptureOverReaperUiIsOsDrag() {
|
||||||
|
DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true};
|
||||||
|
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields.
|
||||||
|
static void testS17FieldsIgnoredWhenNotDragging() {
|
||||||
|
CHECK(decideGesture(500, 150, kPanel,
|
||||||
|
DragState{false, true, true, true}) == DragGesture::None);
|
||||||
|
CHECK(decideGesture(500, 150, kPanel,
|
||||||
|
DragState{true, false, true, true}) == DragGesture::None);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Path-list assembly -------------------------------------------------------
|
// --- Path-list assembly -------------------------------------------------------
|
||||||
|
|
||||||
static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; }
|
static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; }
|
||||||
@@ -180,6 +228,12 @@ int main() {
|
|||||||
testReentryReturnsInternal();
|
testReentryReturnsInternal();
|
||||||
testOffsetPanelRect();
|
testOffsetPanelRect();
|
||||||
|
|
||||||
|
testSingleCaptureOverReaperUiIsInstrumentDrop();
|
||||||
|
testSingleCaptureInsidePanelStaysInternal();
|
||||||
|
testSingleCaptureOffReaperIsOsDrag();
|
||||||
|
testMultiCaptureOverReaperUiIsOsDrag();
|
||||||
|
testS17FieldsIgnoredWhenNotDragging();
|
||||||
|
|
||||||
testSinglePath();
|
testSinglePath();
|
||||||
testMultiPreservesOrder();
|
testMultiPreservesOrder();
|
||||||
testDedupeSamePath();
|
testDedupeSamePath();
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Standalone tests for reasampler::instrument_drop — no REAPER, no VST3 SDK, no framework.
|
||||||
|
// The S17 drop-and-load blob-construction contract: the extension builds a vst_chunk blob
|
||||||
|
// whose bytes are EXACTLY what ReaSampler 9000's own setState (deserializeComponentState)
|
||||||
|
// accepts, with the dragged capture pre-selected. The round-trip proof (build -> base64
|
||||||
|
// decode -> the instrument's OWN reader -> assert the capture selected) IS the cross-artifact
|
||||||
|
// contract guard — the same pattern assignment_request_tests uses for its wire format.
|
||||||
|
|
||||||
|
#include "../src/instrument_drop.h"
|
||||||
|
#include "../src/vst/sample_map.h" // deserializeComponentState — the instrument's OWN reader
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// A representative project rate for the reader (the legacy-v3 conversion parameter; our v5
|
||||||
|
// blob never consumes it, but the reader signature requires a positive rate).
|
||||||
|
static constexpr double kRate = 48000.0;
|
||||||
|
|
||||||
|
// THE contract test: a blob built for a capture id decodes — through the instrument's OWN
|
||||||
|
// reader — to a ComponentState with THAT id selected, no zones, default mono. If this fails,
|
||||||
|
// the extension would inject bytes the instrument's setState rejects and the drop would load
|
||||||
|
// a silent/wrong instance.
|
||||||
|
static void testBlobRoundTripsThroughInstrumentReader() {
|
||||||
|
const std::string id = "cap-7f3a-guid";
|
||||||
|
const std::string b64 = buildInstrumentDropChunk(id);
|
||||||
|
CHECK(!b64.empty());
|
||||||
|
|
||||||
|
const std::vector<std::uint8_t> bytes = decodeBase64(b64);
|
||||||
|
CHECK(!bytes.empty());
|
||||||
|
// The base64 must decode to EXACTLY the pre-encode state bytes (no corruption).
|
||||||
|
CHECK(bytes == instrumentDropStateBytes(id));
|
||||||
|
|
||||||
|
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||||
|
CHECK(cs.selectionId == id); // the capture IS selected — the whole point
|
||||||
|
CHECK(cs.map.zones.empty()); // a drop selects one capture, authors no zones
|
||||||
|
CHECK(cs.channelMode == ChannelMode::Mono); // fresh-instance default
|
||||||
|
CHECK(cs.lastConsumedAssignGeneration == 0); // fresh instance, no consumed assign
|
||||||
|
}
|
||||||
|
|
||||||
|
// A GUID-shaped id with bytes that would trip a naive delimiter-based encoder round-trips
|
||||||
|
// whole (the length-prefixed component-state framing + base64 carry arbitrary bytes).
|
||||||
|
static void testGuidLikeIdRoundTrips() {
|
||||||
|
const std::string id = "{9A2F0C11-4B6E-4D01-8F3A-0011223344FF}";
|
||||||
|
const std::vector<std::uint8_t> bytes = decodeBase64(buildInstrumentDropChunk(id));
|
||||||
|
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||||
|
CHECK(cs.selectionId == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty id yields the empty-state blob: it still decodes cleanly to {"", no zones} — the
|
||||||
|
// S10 silent empty state. (The shell guards against dropping nothing; the pure contract holds.)
|
||||||
|
static void testEmptyIdYieldsEmptyState() {
|
||||||
|
const std::vector<std::uint8_t> bytes = decodeBase64(buildInstrumentDropChunk(""));
|
||||||
|
CHECK(!bytes.empty()); // still a versioned envelope, just an empty selection
|
||||||
|
const ComponentState cs = deserializeComponentState(bytes, kRate);
|
||||||
|
CHECK(cs.selectionId.empty());
|
||||||
|
CHECK(cs.map.zones.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic: the same id always produces the same blob (no time/random in the path).
|
||||||
|
static void testDeterministic() {
|
||||||
|
CHECK(buildInstrumentDropChunk("abc") == buildInstrumentDropChunk("abc"));
|
||||||
|
CHECK(buildInstrumentDropChunk("abc") != buildInstrumentDropChunk("abd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- base64 codec unit coverage (the encode side the shell actually ships) -----
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t> b(std::initializer_list<int> v) {
|
||||||
|
std::vector<std::uint8_t> out;
|
||||||
|
for (int x : v) out.push_back(static_cast<std::uint8_t>(x));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known RFC-4648 vectors, incl. every padding case (0/1/2 trailing bytes).
|
||||||
|
static void testBase64KnownVectors() {
|
||||||
|
CHECK(encodeBase64(b({})) == "");
|
||||||
|
CHECK(encodeBase64(b({'f'})) == "Zg==");
|
||||||
|
CHECK(encodeBase64(b({'f', 'o'})) == "Zm8=");
|
||||||
|
CHECK(encodeBase64(b({'f', 'o', 'o'})) == "Zm9v");
|
||||||
|
CHECK(encodeBase64(b({'f', 'o', 'o', 'b'})) == "Zm9vYg==");
|
||||||
|
CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a'})) == "Zm9vYmE=");
|
||||||
|
CHECK(encodeBase64(b({'f', 'o', 'o', 'b', 'a', 'r'})) == "Zm9vYmFy");
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode -> decode is identity across every residue class + all-byte values.
|
||||||
|
static void testBase64RoundTripAllBytes() {
|
||||||
|
for (int len = 0; len <= 300; ++len) {
|
||||||
|
std::vector<std::uint8_t> in;
|
||||||
|
for (int i = 0; i < len; ++i) in.push_back(static_cast<std::uint8_t>((i * 37 + 11) & 0xFF));
|
||||||
|
CHECK(decodeBase64(encodeBase64(in)) == in);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed decode inputs return empty (never throw / never UB): bad length, illegal char,
|
||||||
|
// misplaced padding.
|
||||||
|
static void testBase64DecodeRejectsMalformed() {
|
||||||
|
CHECK(decodeBase64("Zg=").empty()); // length not a multiple of 4
|
||||||
|
CHECK(decodeBase64("Zm9v!ba=").empty()); // illegal char '!'
|
||||||
|
CHECK(decodeBase64("Z===").empty()); // illegal char in v1 position
|
||||||
|
CHECK(decodeBase64("Zg==Zg==").empty()); // interior padding (pad before the final quad)
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testBlobRoundTripsThroughInstrumentReader();
|
||||||
|
testGuidLikeIdRoundTrips();
|
||||||
|
testEmptyIdYieldsEmptyState();
|
||||||
|
testDeterministic();
|
||||||
|
testBase64KnownVectors();
|
||||||
|
testBase64RoundTripAllBytes();
|
||||||
|
testBase64DecodeRejectsMalformed();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user