Ψ-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:
2026-08-01 19:43:37 -04:00
parent 8bf6841f7b
commit fe3ac79ab5
17 changed files with 842 additions and 398 deletions
+19 -10
View File
@@ -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.
+39 -8
View File
@@ -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
View File
@@ -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 -------------------------------------------------------