Ψ-W1-T4: resolve drop targets per move, not once — every surface gets a defined outcome, a cue, and no silent no-op
This commit is contained in:
+19
-10
@@ -76,14 +76,20 @@ L7 sub-pass, 2026-07-27):
|
||||
geometry modules; the kit's *draw* half is shell, its *geometry* half is pure,
|
||||
even where a WDL piece is reused. Look-and-feel work never touches capture,
|
||||
placement, or bank data ownership.
|
||||
- **L7 drag-gesture precedence is a pure decision helper.** The rule — leave
|
||||
client rect → OS drag-out; else drop on a tab/other bank → move/copy; else
|
||||
same-bank grid → reorder-to-slot (empty slot = place, occupied + no modifier =
|
||||
insert-before-and-shift, occupied + Alt = replace) — is "encoded in a pure
|
||||
decision helper (mirror `drag_out::decideGesture`)"; the shell only reads live
|
||||
pointer/focus/client-rect/modifier state and calls it, then maps the resolved
|
||||
gesture to a cursor via `SetCursor`. No cue or precedence logic belongs in the
|
||||
shell.
|
||||
- **Drag-gesture precedence is a pure decision helper, on both sides of the client
|
||||
rect.** Inside: drop on a tab/other bank → move/copy; else same-bank grid →
|
||||
reorder-to-slot (empty slot = place, occupied + no modifier =
|
||||
insert-before-and-shift, occupied + Alt = replace). Outside: `decideDropClass`
|
||||
resolves the surface under the cursor. The shell only reads live
|
||||
pointer/focus/client-rect/modifier state and calls these, then maps the resolved
|
||||
cue to a cursor via `SetCursor`. No cue or precedence logic belongs in the shell.
|
||||
- **The drag-out law is per-move and stateless.** The class is resolved from the
|
||||
current pointer on every move and again at the release point; nothing is latched
|
||||
between evaluations. That is what makes every transition reversible and what
|
||||
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
|
||||
REAPER entirely is the one irreversible transition, because the OS hand-off goes
|
||||
modal. A first-move class lock and a drag-lifetime "cannot hand off" latch both
|
||||
existed here and were removed — do not reintroduce either.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -91,7 +97,7 @@ L7 sub-pass, 2026-07-27):
|
||||
- `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel.
|
||||
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
|
||||
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
|
||||
- `drag_out` — pure OS drag-out module: gesture-boundary decision and path-list assembly. The `InstrumentDrop` gesture signals that the shell should execute an instrument-drop rather than a file-copy drag.
|
||||
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (OffReaper / TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
|
||||
- `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).
|
||||
@@ -119,9 +125,12 @@ L7 sub-pass, 2026-07-27):
|
||||
worked example. A prior revision wrote the threshold ~25% low and let 15px
|
||||
semibold clear a floor it was not entitled to.
|
||||
- `card_drag`'s precedence order must stay a pure decision helper mirroring
|
||||
`drag_out::decideGesture` — don't let a shell reimplement gesture precedence
|
||||
`drag_out::decideDropClass` — don't let a shell reimplement gesture precedence
|
||||
ad hoc; the cursor-cue mapping in the shell must stay a thin lookup over the
|
||||
pure result.
|
||||
- `drag_out` is deliberately **dependency-free**, and `instrument_drop` links it
|
||||
rather than the reverse. Inverting that edge would drag the instrument's state
|
||||
serializer into `card_drag` and into every `drag_out` consumer.
|
||||
- `rect`'s prior role names survive only as `using` aliases at their old call
|
||||
sites — changing `rect.h` itself ripples across every directory that aliases
|
||||
it (e.g. `editor_geometry::Rect`); check all alias sites, not just this one.
|
||||
|
||||
@@ -15,14 +15,45 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
|
||||
|
||||
} // namespace
|
||||
|
||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state) {
|
||||
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
|
||||
if (insideClient(px, py, client)) return DragGesture::Internal;
|
||||
// Outside the client: a single-capture drag still over REAPER's own UI is an instrument
|
||||
// drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out.
|
||||
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
|
||||
return DragGesture::OsDrag;
|
||||
DropClass decideDropClass(int px, int py, const PanelClientRect& client,
|
||||
const DropContext& ctx) {
|
||||
if (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
|
||||
if (insideClient(px, py, client)) return DropClass::Internal;
|
||||
|
||||
switch (ctx.surface) {
|
||||
case ReaperSurface::OffReaper:
|
||||
return DropClass::OsHandoff;
|
||||
|
||||
case ReaperSurface::TrackPanel:
|
||||
case ReaperSurface::FxSurface:
|
||||
// One instance holds one capture, so a multi payload names no instrument to build —
|
||||
// it refuses with a cue rather than falling through to some other surface's outcome.
|
||||
return (ctx.singlePayload && ctx.haveTrack) ? DropClass::InstrumentDrop
|
||||
: DropClass::Refuse;
|
||||
|
||||
case ReaperSurface::Arrange:
|
||||
// GetThingFromPoint may return a null track with a valid info string (the SDK says
|
||||
// so): over the arrange that is the region below the last track, which names no lane
|
||||
// to place on. Refuse rather than guess a track.
|
||||
return ctx.haveTrack ? DropClass::ArrangeInsert : DropClass::Refuse;
|
||||
|
||||
case ReaperSurface::FxEmbed:
|
||||
case ReaperSurface::Other:
|
||||
return DropClass::Refuse;
|
||||
}
|
||||
return DropClass::Refuse; // an unclassifiable surface still refuses visibly, never silently
|
||||
}
|
||||
|
||||
DropCue cueForDropClass(DropClass cls) {
|
||||
switch (cls) {
|
||||
case DropClass::Internal: return DropCue::Internal;
|
||||
case DropClass::InstrumentDrop: return DropCue::Instrument;
|
||||
case DropClass::ArrangeInsert: return DropCue::ArrangeInsert;
|
||||
case DropClass::Refuse: return DropCue::Refuse;
|
||||
case DropClass::OsHandoff: return DropCue::OsOwned;
|
||||
case DropClass::None: return DropCue::None;
|
||||
}
|
||||
return DropCue::None;
|
||||
}
|
||||
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||
|
||||
+51
-24
@@ -1,15 +1,15 @@
|
||||
#pragma once
|
||||
#include "core/ui/rect.h"
|
||||
// drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and
|
||||
// the panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp).
|
||||
// drag_out — the pure drag-out gesture law plus the OS hand-off's path-list assembly. REAPER
|
||||
// hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
|
||||
// instrument_drop_win / arrange_drop_win / drag_out_win).
|
||||
//
|
||||
// Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank
|
||||
// region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that
|
||||
// rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another
|
||||
// window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own
|
||||
// UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary.
|
||||
// THE LAW: the class is resolved from what is under the cursor on EVERY move; every transition
|
||||
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is
|
||||
// reserved for leaving REAPER, and every REAPER-internal target executes natively on release.
|
||||
// Do not reintroduce a first-move class lock or a drag-lifetime "blocked" latch.
|
||||
//
|
||||
// Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries
|
||||
// Path-list assembly: turns armed sample ids into the absolute path list an OS drop carries
|
||||
// (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id
|
||||
// to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win).
|
||||
|
||||
@@ -18,34 +18,61 @@
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
// --- Gesture boundary ---------------------------------------------------------
|
||||
// --- Gesture law --------------------------------------------------------------
|
||||
|
||||
// The panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x
|
||||
// [y, y+height).
|
||||
using PanelClientRect = Rect;
|
||||
|
||||
// Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet
|
||||
// dragging" is not a drag for this decision.
|
||||
// Live drag state reduced to what every gesture decision needs. Pre-threshold "armed but not
|
||||
// yet dragging" is not a drag. Shared with card_drag's in-grid precedence decision.
|
||||
struct DragState {
|
||||
bool dragging = false; // threshold crossed; a drag is in progress
|
||||
bool hasArmedSamples = false; // payload holds >= 1 sample id
|
||||
bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop)
|
||||
bool overReaperUi = false; // pointer is over REAPER's own UI (shell-supplied)
|
||||
};
|
||||
|
||||
// What the shell should do with the drag given the current pointer position.
|
||||
enum class DragGesture {
|
||||
None, // no drag under way, or an empty payload
|
||||
Internal, // dragging inside the panel — bank-to-bank move/copy
|
||||
InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell
|
||||
// hover-tracks the TCP FX button; on release adds a preloaded instance
|
||||
OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS
|
||||
// What REAPER reports under the pointer, reduced to the surfaces the law distinguishes.
|
||||
// Produced from GetThingFromPoint's info token by wire::classifyReaperSurface.
|
||||
enum class ReaperSurface {
|
||||
OffReaper, // not over REAPER at all — the one irreversible exit
|
||||
TrackPanel, // TCP/MCP, ANY sub-element: the WHOLE panel is the instrument hotspot
|
||||
FxSurface, // fx_* — the FX chain and floating-FX windows
|
||||
FxEmbed, // tcp.fxembed / mcp.fxembed — an instance already draws there
|
||||
Arrange, // the timeline
|
||||
Other, // ruler, transport, spacers, docker chrome, unknown future tokens
|
||||
};
|
||||
|
||||
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only
|
||||
// + state-only (no hidden state), so re-entry back inside always returns Internal.
|
||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state);
|
||||
// The resolved target class. Every value is a DEFINED outcome the shell executes or visibly
|
||||
// refuses — there is deliberately no "nothing happens" member, which is what makes "no silent
|
||||
// no-op release" a property of the enumeration rather than of any one call site.
|
||||
enum class DropClass {
|
||||
None, // no drag under way, or an empty payload — nothing to resolve
|
||||
Internal, // inside the panel client — the bank-to-bank drag, unchanged
|
||||
InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
|
||||
ArrangeInsert, // the timeline — place items at the pointer's track and time
|
||||
Refuse, // a REAPER surface with no defined outcome for this payload — cue it
|
||||
OsHandoff, // the pointer left REAPER — hand the file list to the OS
|
||||
};
|
||||
|
||||
// Everything the law reads. Nothing here is remembered between evaluations: two evaluations at
|
||||
// the same point with the same payload resolve identically, whatever happened in between.
|
||||
struct DropContext {
|
||||
DragState drag;
|
||||
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
|
||||
ReaperSurface surface = ReaperSurface::OffReaper; // only read outside the client rect
|
||||
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack*
|
||||
};
|
||||
|
||||
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
|
||||
// so re-entry into the client always returns Internal and a surface transition always reverses.
|
||||
DropClass decideDropClass(int px, int py, const PanelClientRect& client, const DropContext& ctx);
|
||||
|
||||
// The pointer cue for a class, so "will this work" is visible BEFORE release. Internal defers
|
||||
// to card_drag's own cue; OsOwned means the OS drag loop draws the cursor and we must not fight
|
||||
// it.
|
||||
enum class DropCue { None, Internal, Instrument, ArrangeInsert, Refuse, OsOwned };
|
||||
|
||||
DropCue cueForDropClass(DropClass cls);
|
||||
|
||||
// --- Path-list assembly -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ This directory owns two cross-artifact contracts specifically:
|
||||
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
|
||||
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
|
||||
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -18,6 +18,9 @@ reasampler_test(sample_usage LINK sample_usage prune_reconcile)
|
||||
# parallel byte writer, so the cross-artifact contract cannot drift — hence the link to
|
||||
# component_state_io, which stays engine-free. The class-ID string derives from the frozen
|
||||
# UID macros, channel-selected via the generated version header, hence its include dir.
|
||||
reasampler_pure_library(instrument_drop SOURCES instrument_drop.cpp LINK PUBLIC component_state_io)
|
||||
# drag_out is the dependency-free owner of the ReaperSurface vocabulary this module classifies
|
||||
# INTO; the edge points this way so drag_out (and card_drag through it) stays free of the
|
||||
# serializer.
|
||||
reasampler_pure_library(instrument_drop SOURCES instrument_drop.cpp LINK PUBLIC component_state_io drag_out)
|
||||
target_include_directories(instrument_drop PUBLIC ${PROJECT_BINARY_DIR}/generated)
|
||||
reasampler_test(instrument_drop LINK instrument_drop)
|
||||
|
||||
@@ -87,11 +87,16 @@ DropOutcome decideDropOutcome(const DropAttempt& attempt) {
|
||||
return out;
|
||||
}
|
||||
|
||||
bool infoNamesFxHotspot(const std::string& info) {
|
||||
// See the header contract for the prefix rule and the embed-strip exclusion.
|
||||
ui::ReaperSurface classifyReaperSurface(const std::string& info, bool haveTrack) {
|
||||
// See the header contract for the prefix rule and the ordering it depends on.
|
||||
auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; };
|
||||
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return false;
|
||||
return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx");
|
||||
|
||||
if (info.empty()) return haveTrack ? ui::ReaperSurface::Other : ui::ReaperSurface::OffReaper;
|
||||
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return ui::ReaperSurface::FxEmbed;
|
||||
if (startsWith("fx_")) return ui::ReaperSurface::FxSurface;
|
||||
if (startsWith("tcp") || startsWith("mcp")) return ui::ReaperSurface::TrackPanel;
|
||||
if (startsWith("arrange")) return ui::ReaperSurface::Arrange;
|
||||
return ui::ReaperSurface::Other;
|
||||
}
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
// instrument_drop — pure payload-construction core of drop-and-load: dropping a
|
||||
// bank capture onto a track's FX surface instantiates ReaSampler 9000 on that
|
||||
// track already playing that capture. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// bank capture onto a track's panel or FX surface instantiates ReaSampler 9000 on
|
||||
// that track already playing that capture. Also the classifier that names those
|
||||
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// (+ the pure sample_map it reuses and the SDK-free UID macros in
|
||||
// reasampler_uid.h); unit-tested outside the DAW.
|
||||
//
|
||||
@@ -25,6 +26,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/ui/drag_out.h" // ReaperSurface — the surface vocabulary the gesture law reads
|
||||
|
||||
namespace reasampler::wire {
|
||||
|
||||
// The 32-char uppercase-hex class-ID string of this build's channel-active
|
||||
@@ -52,16 +55,23 @@ std::vector<std::uint8_t> buildVstPresetBytes(const std::string& classIdHex32,
|
||||
// preset. Deterministic.
|
||||
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId);
|
||||
|
||||
// Pure classifier for GetThingFromPoint's info string: is the point over a
|
||||
// surface where an instrument drop should instantiate ReaSampler 9000? The
|
||||
// SDK warns future versions may append information, so the rule is
|
||||
// PREFIX-based: "fx_" (FX-chain/floating windows) or "tcp.fx"/"mcp.fx" (the
|
||||
// TCP/MCP FX button + sibling elements) EXCEPT "tcp.fxembed"/"mcp.fxembed" —
|
||||
// the embed-strip surface where an instance already draws; dropping there
|
||||
// must not add a second instance. Bare "tcp"/"mcp" and non-FX sub-elements
|
||||
// are not hotspots. The exact live token is DAW-only — confirm via
|
||||
// Pure classifier for GetThingFromPoint's (info string, track-was-returned) pair
|
||||
// into the surfaces the drag-out gesture law distinguishes. The SDK warns future
|
||||
// versions may append information, so every rule is PREFIX-based:
|
||||
// "tcp.fxembed*" / "mcp.fxembed*" -> FxEmbed. Checked FIRST: it is the surface
|
||||
// an existing instance already draws on, and a drop must not stack a second.
|
||||
// "fx_*" -> FxSurface (FX chain + floating FX windows).
|
||||
// "tcp*" / "mcp*" -> TrackPanel — the WHOLE panel, every
|
||||
// sub-element, not just the FX button. A TCP too narrow to draw that button
|
||||
// still means "sampler on this track", and the old glyph-only rule is exactly
|
||||
// why such a drop landed nowhere.
|
||||
// "arrange*" -> Arrange.
|
||||
// "" with no track -> OffReaper (the pointer left REAPER).
|
||||
// anything else, incl. "" WITH a track -> Other. Over REAPER on a surface we
|
||||
// cannot name: refuse visibly, never guess an outcome.
|
||||
// The exact live token is DAW-only — confirm via
|
||||
// reaper.GetThingFromPoint(reaper.GetMousePosition()) in ReaScript if unsure.
|
||||
bool infoNamesFxHotspot(const std::string& info);
|
||||
ui::ReaperSurface classifyReaperSurface(const std::string& info, bool haveTrack);
|
||||
|
||||
// The raw component-state bytes the preset carries, exposed so the round-trip
|
||||
// test can decode them back through sample_map::deserializeComponentState and
|
||||
|
||||
Reference in New Issue
Block a user