Q-W2: split bank_panel.cpp (3459 LOC) into eight shell/panel TUs — reasampler::panel internals, per-seam public headers, shim retired; zero behavior change, 60/60 green

This commit is contained in:
2026-07-29 10:55:58 -04:00
parent b5788c82f6
commit 30a4ffd01b
20 changed files with 4084 additions and 3596 deletions
+628
View File
@@ -0,0 +1,628 @@
// panel_input.cpp — the input + detection seam of the docked bank panel (Q-W2 split
// of bank_panel.cpp). Owns left-click / wheel / keyboard routing (plain free-function
// calls on the per-event path — T4-28), the accelerator registration, the tail-setting
// read/mutate helpers, and the timer-driven new-content auto-tag detection (D2 Wave 2).
// Mouse-MOVE (hover + the card-drag state machine) lives in panel_drag; the bank-change
// fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates).
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are
// extern (CLAUDE.md §contract). DAW-verified, not unit tested.
#include <map>
#include <set>
#include <string>
#include <vector>
#include "shell/panel/panel_state.h"
#include "shell/panel/panel_input.h"
#include "actions.h" // bankPruneCommandId — the footer Prune dispatch (R3)
#include "persist.h" // ReaSamplerSession — view/tail reads + mutation
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag (D2 Wave 2)
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation (D2/D4)
// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane
// state to classify an item's lane as managed vs manual.
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_MarkProjectDirty
#define REAPERAPI_WANT_Main_OnCommand
#include "reaper_plugin_functions.h"
// main.cpp owns REAPER's dispatch struct (the accelerator registers through it).
extern reaper_plugin_info_t* g_rec;
namespace reasampler::panel {
// The session's live tail setting (default None / 2 s when no session). Single read
// point so draw, wheel-adjust, and the capture read seam all agree on the source.
TailSetting currentTail() {
return g_panel.session ? g_panel.session->tail() : TailSetting{};
}
// Commits the current tail setting to ext state and marks the active project dirty
// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only
// path that calls SetProjExtState for the tail key — calling it here closes the gap
// where toggle/scroll would dirty the project but the new value was never written.
// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h).
// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way.
// NON-DESTRUCTIVE: touches nothing in the bank/arrange.
void markTailDirty() {
if (g_panel.session) g_panel.session->saveToActiveProject();
ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (proj) MarkProjectDirty(proj);
}
// Routes a click in a toolbar to the hit button's action, fired through the command-id contract
// (Main_OnCommand — REAPER runs the SAME action a keybinding would). Returns true iff the click
// was inside the bar band (handled, or a harmless gap/overflow/unregistered no-op), so the
// caller stops before grid handling. `rows` is the toolbar's inventory.
bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
const std::vector<ActionBarRow>& rows) {
if (bar.height <= 0) return false;
const int hit = toolbarHit(x, y, bar, rows);
if (hit < 0) {
// Inside the band but in a gap / overflow dead-zone: claim it so it never falls through
// to the grid. Outside the band: not ours.
return y >= bar.y && y < bar.y + bar.height &&
x >= bar.x && x < bar.x + bar.width;
}
const ActionBarRow& row = rows[static_cast<std::size_t>(hit)];
// A disabled button (L5 opposite-mode gate) is claimed but no-ops — the click never fires the
// action and never falls through to the grid (a dead button reads as inert, not absent).
if (!row.enabled) return true;
const int cmd = resolveBarCommandId(row);
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
return true;
}
// --- New-content detection (D2 Wave 2) ----------------------------------------
//
// REAPER exposes no "item/track added" callback, so we diff live project state on the
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
// membership index (persisted by persist on the next save, same as an action-driven tag).
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
constexpr int kFreeModeFixedLanes = 2;
bool isFixedLaneTrack(MediaTrack* tr) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
}
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies.
// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
// baseline input) and, for each item, records whether it sits on a manual lane so a
// newly-detected item can be exempted from auto-tag without a second project walk.
// `trackItemGuids` additionally maps each track GUID to the item GUIDs it carries, so a
// newly-detected item's PRE-EXISTING siblings can be resolved (the adoption / strand
// guard) without a second project walk.
//
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
// rule is defined in exactly one place and is unit-tested there.
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
std::map<std::string, bool>& itemOnManualLane,
std::map<std::string, std::vector<std::string>>& trackItemGuids) {
const int trackCount = CountTracks(proj);
for (int t = 0; t < trackCount; ++t) {
MediaTrack* tr = GetTrack(proj, t);
if (!tr) continue;
std::string tg = guidString(tr);
if (!tg.empty()) allGuids.insert(tg);
// Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
// track-level attribute and is the same for every item on the track.
const bool fixedLane = isFixedLaneTrack(tr);
std::vector<std::string>& itemsOnTrack = trackItemGuids[tg];
const int itemCount = CountTrackMediaItems(tr);
for (int i = 0; i < itemCount; ++i) {
MediaItem* it = GetTrackMediaItem(tr, i);
if (!it) continue;
std::string ig = itemGuid(it);
if (ig.empty()) continue;
allGuids.insert(ig);
// Classify via the single shared predicate. For a fixed-lane track we read
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
// false immediately for non-fixed-lane tracks regardless of name).
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
itemsOnTrack.push_back(ig);
}
}
}
// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
// into the active mode. Runs every timer tick regardless of panel open/close (content
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
// membership index.
//
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
// background metadata update (like setting a label), not a destructive project edit.
// persist.cpp writes it on the next project save alongside the bank and view state, the
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
// the REAPER undo history with a new entry for every timer tick that sees new content.
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
// caller uses to decide whether to run the lane-minting pass (a track can only newly
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
bool detectNewContent() {
if (!g_panel.session) return false;
ReaProject* proj = EnumProjects(-1, nullptr, 0);
// A project (re)load re-arms the first-poll guard so we never diff across two
// projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded()
// on the tick persist restores the project's membership + active mode, which sets
// reloadPending. Draining it here re-baselines against the fully-loaded set (that
// same tick's reapply-active-mode enumerated those tracks, so they are present),
// and the observe() below returns nothing new — pre-existing untagged tracks stay
// Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so
// no separate first-tick handling is needed here. Using persist's GUID-primary load
// signal (not a local pointer compare) is what fixes the reload-mis-tag: the two
// identity checks can no longer diverge on a recycled ReaProject* address.
if (g_panel.reloadPending) {
g_panel.contentBaseline.reset();
g_panel.reloadPending = false;
}
std::set<std::string> live;
std::map<std::string, bool> itemOnManualLane;
std::map<std::string, std::vector<std::string>> trackItemGuids;
enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
if (added.empty()) return false; // first poll after open, or nothing new this tick
ViewModeModel& model = g_panel.session->view();
// Which of `added` are items (the manual-lane map keys every item; track GUIDs never
// appear there). Used below to exclude sibling new items from a track's PRE-EXISTING
// mode set — a drop plus its own new siblings must not count each other as prior.
const std::set<std::string> newItemGuids = [&] {
std::set<std::string> s;
for (const std::string& g : added)
if (itemOnManualLane.count(g)) s.insert(g);
return s;
}();
// Item guid -> its track guid (reverse of trackItemGuids), so a new item's siblings
// are found in one lookup.
std::map<std::string, std::string> trackOfItem;
for (const auto& [trackGuid, items] : trackItemGuids)
for (const std::string& ig : items) trackOfItem[ig] = trackGuid;
// The distinct modes the PRE-EXISTING (not-new-this-tick) MANAGED-ELIGIBLE items on
// `trackGuid` resolve to. Untagged siblings resolve to Arrange (leafBelongsToMode's
// default); new siblings are excluded; manual-lane siblings are EXEMPT — exactly as
// planLaneMinting ignores them when computing a track's own-item mode span, so the
// adoption guard's view of the track matches the split decision's. Drives the adoption
// / strand guard in autoTagNewContent.
const auto preExistingTrackModes =
[&](const std::string& trackGuid) -> std::set<std::string> {
std::set<std::string> modes;
auto it = trackItemGuids.find(trackGuid);
if (it == trackItemGuids.end()) return modes;
for (const std::string& sib : it->second) {
if (newItemGuids.count(sib)) continue; // a sibling added THIS tick — not prior
auto ml = itemOnManualLane.find(sib);
if (ml != itemOnManualLane.end() && ml->second) continue; // manual lane — exempt
const std::set<std::string> m = model.membership().modesOf(sib);
if (m.empty()) modes.insert(kArrangeModeId); // untagged ⇒ Arrange default
else modes.insert(m.begin(), m.end());
}
return modes;
};
// Split the new GUIDs into tracks vs items so the pure decision can apply the
// manual-lane exemption to items only. A GUID present in the item-lane map is an
// item; otherwise it is a track (track GUIDs never appear in that map).
std::vector<std::string> newTracks;
std::vector<NewItem> newItems;
for (const std::string& g : added) {
auto it = itemOnManualLane.find(g);
if (it == itemOnManualLane.end()) {
newTracks.push_back(g); // a track GUID
} else {
NewItem ni{g, it->second, {}};
auto tk = trackOfItem.find(g);
if (tk != trackOfItem.end()) ni.trackModes = preExistingTrackModes(tk->second);
newItems.push_back(std::move(ni)); // an item; carries exemption + track modes
}
}
const std::vector<AutoTag> tags =
autoTagNewContent(newTracks, newItems, model.activeModeId());
for (const AutoTag& tag : tags)
model.membership().tag(tag.guid, tag.modeId);
return !tags.empty();
}
// The item count the SELECTION reasons over — the focused region's occupied-cell count.
// L7: selection/navigation traverse OCCUPIED cells only (empty slots are gaps, not
// selectable). Occupied count == index size by construction: every index member maps to
// exactly one occupied slot (gaps are empty slots, which the index never backs), so the
// raw index size IS the dense selection-space extent.
int focusedItemCount() {
const BankModel* idx = indexForRegion(g_panel.focusedRegion);
return idx ? static_cast<int>(idx->size()) : 0;
}
// --- Click routing ------------------------------------------------------------
// Handles a header/tab-strip/button click for the banks region. Returns true if the
// click was consumed (a region-chrome hit), false to fall through to grid selection.
bool handleBanksChromeClick(int x, int y, const RECT& region) {
// Full-height toggle button.
const RECT ftb = fullHtBtnRect(region);
if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
bankPanelToggledBanksFullHeight();
return true;
}
// "+" create button.
const RECT cb = createBtnRect(region);
if (x >= cb.left && x < cb.right && y >= cb.top && y < cb.bottom) {
doCreateBank();
return true;
}
// Tab strip: chevrons scroll, a tab click SHOWS that bank (browse — NOT activate).
const TabStripRect strip = banksTabStripRect(region);
const std::vector<const Bank*> tabs = namedBanks();
const int n = static_cast<int>(tabs.size());
const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll);
if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) {
const TabStripLayout layout =
computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll);
const int step = kTabSpec.tabWidth;
const int desired = g_panel.tabScroll +
(hit.kind == TabHitKind::ScrollLeft ? -step : step);
g_panel.tabScroll = clampTabScroll(desired, layout);
invalidatePanel();
return true;
}
if (hit.kind == TabHitKind::Tab) {
const Bank* bk = tabs[static_cast<std::size_t>(hit.index)];
if (bk->id != g_panel.shownBankId) {
g_panel.shownBankId = bk->id; // browse: show this bank's grid
g_panel.selection = Selection{}; // grid changed — reset selection
stopAudition();
}
g_panel.focusedRegion = Region::Banks;
invalidatePanel();
return true;
}
return false;
}
// Handles the pool region's full-height toggle. Returns true if consumed.
bool handlePoolChromeClick(int x, int y, const RECT& region) {
const RECT ftb = fullHtBtnRect(region);
if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
bankPanelToggledPoolFullHeight();
return true;
}
return false;
}
// Applies a left-click at (x, y): route to top toolbar / footer (toggle / Tail / Prune) /
// bottom toolbar / region chrome / grid selection, and arm a potential drag when the click
// lands on a selected cell. L4 order mirrors the three-zone layout top-to-bottom.
void handleClick(int x, int y) {
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
// TOP toolbar: the far-right More button first (its rect sits in the band's reserved right
// strip, outside the action rect), then the frequent capture/placement buttons. A button
// fires its registered action via the command-id contract; the band is claimed whole (a
// gap/overflow miss is a harmless no-op, never a fall-through). Capture never auto-inserts.
{
const MenuButtonRect mb = topMenuButtonRect(w);
if (hitTestMenuButton(x, y, mb)) { showMoreMenu(); return; }
}
if (handleToolbarClick(x, y, topToolbarActionRect(w), topBarRows())) return;
// Claim the WHOLE top band (including the reserved right strip between the last button and
// the More button) so a click there is inert chrome, never a fall-through to the grid.
if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return;
// Footer: mode toggle (left) -> Tail button -> Prune (right). The narrow [Arrange|Design]
// toggle activates that mode; the Tail button cycles the tail setting (L4 §4 — was a
// click-zone); Prune fires the guarded prune command. Checked before the bottom toolbar /
// grid so a footer click never selects a cell.
{
const int seg = footerToggleSegmentHit(x, y, w, h);
if (seg >= 0) {
const std::vector<Mode>& modes = g_panel.session->view().modes().all();
if (seg < static_cast<int>(modes.size())) {
applyMode(g_panel.session->view(),
modes[static_cast<std::size_t>(seg)].id, nullptr);
invalidatePanel();
}
return;
}
const FooterBarLayout fb = footerBarLayoutFor(w, h);
if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) {
// Tail button click cycles the tail mode (None -> Auto -> Manual -> None). Mutates
// the SESSION's tail setting (capture reads it; persist saves it with the project)
// and marks the project dirty — touches NOTHING in the bank/arrange.
TailSetting& tail = g_panel.session->tail();
tail.mode = cycleTailMode(tail.mode);
markTailDirty();
invalidatePanel();
return;
}
// Prune button (R3): fires the "Prune bank folder" action THROUGH its registered
// command id (fork R-E: dispatch the command, not the session directly) so the panel
// affordance and the bindable action share the one guarded dry-run/confirm/delete path
// in doBankPruneFolder. A 0 id (pre-registration) no-ops.
const ButtonRect pb = pruneButtonRectFor(w, h);
if (hitTestPruneButton(x, y, pb)) {
const int cmd = bankPruneCommandId();
if (cmd != 0) Main_OnCommand(cmd, 0);
return;
}
}
// BOTTOM toolbar (Design-View verbs): a button fires its registered action via the
// command-id contract. Claimed whole like the top toolbar.
if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;
// Region chrome (headers, tab strip, buttons).
if (poolShown()) {
const RECT pr = poolRegionRect(w, h);
if (y >= pr.top && y < regionGridRect(pr, false).top) {
if (handlePoolChromeClick(x, y, pr)) return;
}
}
if (banksShown()) {
const RECT br = banksRegionRect(w, h);
if (y >= br.top && y < regionGridRect(br, true).top) {
if (handleBanksChromeClick(x, y, br)) return;
}
}
// Grid selection. Resolve which region's grid the point is in.
Region reg = Region::Pool;
if (!regionAt(x, y, reg)) return;
const bool isBanks = reg == Region::Banks;
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
// L7: hit-test the SPARSE slot layout, then map the slot to a selection ordinal. An
// empty (gap) slot hits selectionForSlot == -1, which reads as a grid miss below (a
// click on a gap clears selection, exactly like a click in the margin) — empty slots
// are decorative, not selectable.
const RegionDisplay disp = regionDisplay(region, isBanks, reg);
const int hitSlot = hitTestSlot(x, y, disp.slotRects);
const int hit = hitSlot < 0 ? -1 : disp.selectionForSlot(hitSlot);
const int count = disp.occupiedCount();
// Switching focus region reseeds the selection there.
if (g_panel.focusedRegion != reg) {
g_panel.focusedRegion = reg;
g_panel.selection = Selection{};
stopAudition();
}
if (hit < 0) {
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
g_panel.selection = Selection{};
stopAudition();
}
invalidatePanel();
return;
}
// Drag-arm disambiguation for plain (no ctrl, no shift) presses on a grid cell:
//
// • Already-selected cell: defer the selection change to LBUTTONUP so a plain
// press on a multi-selection doesn't collapse it before we know whether a drag
// will happen. Arm the drag with the current (multi-)selection as the payload
// candidate; only the caret moves immediately.
//
// • Unselected cell: apply the plain-click selection immediately (collapses to
// the single pressed cell) THEN arm a drag from it — so the user can press-and-
// drag in one gesture without a prior selecting click. The selection is set
// before arming so that focusedSelectionIds() resolves the right payload when
// the threshold is crossed in onMouseMove.
//
// ctrl / shift presses are selection-only gestures — no drag arm in either case.
const bool onSelected = g_panel.selection.contains(hit);
if (!ctrlDown() && !shiftDown()) {
if (!onSelected) {
// Commit the single-cell selection now so the drag payload is correct.
g_panel.selection = applyClick(g_panel.selection, hit, false, false, count);
g_panel.selItemCount = count;
} else {
// Move the caret to the pressed cell; defer collapsing multi-selection.
g_panel.selection.focus = hit;
}
g_panel.dragArmed = true;
g_panel.dragStartX = x;
g_panel.dragStartY = y;
g_panel.dragSourceRegion = reg;
// Capture the mouse NOW so WM_MOUSEMOVE is delivered even when the pointer leaves the
// panel client rect before the drag threshold is crossed. Without capture, outside moves
// are not delivered, so a fast straight-out drag never transitions dragArmed → dragging
// and the OS drag-out never fires on the first pass. The capture is released on button-up
// (no drag: onLBtnUp dragArmed branch; drag: OsDrag path or onLBtnUp dragging branch)
// and on WM_CAPTURECHANGED (stolen or external release — already calls resetDragState).
SetCapture(g_panel.hwnd);
invalidatePanel();
return;
}
g_panel.selection = applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count);
g_panel.selItemCount = count;
invalidatePanel();
}
// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`.
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is
// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens,
// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns
// false so the caller can let REAPER/the docker handle the wheel normally). On a real
// change it mutates the SESSION's tail setting, marks the project dirty (so it saves),
// and repaints the live length. Returns true iff the wheel was consumed.
bool handleWheel(int x, int y, int delta) {
if (!g_panel.session) return false;
if (!pointInFooter(x, y)) return false;
TailSetting& tail = g_panel.session->tail();
if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only
// One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
// that sends fractional deltas still steps predictably. Sign carries direction.
const int notches = delta / 120;
if (notches == 0) return false; // sub-notch movement — nothing to apply yet
const double before = tail.manualMs;
tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
if (tail.manualMs == before) return true; // already at a bound — consumed, no change
markTailDirty();
invalidatePanel(); // label shows the new length live
return true;
}
bool isOurWindow(HWND hwnd) {
for (HWND w = hwnd; w; w = GetParent(w))
if (w == g_panel.hwnd) return true;
return false;
}
bool handleKey(int vk) {
const int count = focusedItemCount();
if (count <= 0) return false;
switch (vk) {
case VK_LEFT:
case VK_RIGHT:
case VK_UP:
case VK_DOWN: {
const NavKey nk = vk == VK_LEFT ? NavKey::Left
: vk == VK_RIGHT ? NavKey::Right
: vk == VK_UP ? NavKey::Up
: NavKey::Down;
g_panel.selection = navigate(g_panel.selection, nk,
columnsForRegion(g_panel.focusedRegion),
count, shiftDown());
g_panel.selItemCount = count;
invalidatePanel();
return true;
}
case VK_RETURN:
case VK_SPACE:
if (g_panel.selection.focus >= 0)
startAudition(g_panel.selection.focus);
return true;
case VK_ESCAPE:
stopAudition();
return true;
case VK_DELETE: {
// Remove the focused-region selection (B5). Silent; a no-op when nothing
// is selected.
const std::vector<std::string> sel = focusedSelectionIds();
if (sel.empty()) return false; // nothing selected — let the key fall through
removeSamples(sel, bankIdForRegion(g_panel.focusedRegion));
return true;
}
default:
return false;
}
}
int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) {
if (!msg || msg->message != WM_KEYDOWN) return 0;
if (!g_panel.open || !g_panel.hwnd) return 0;
if (!isOurWindow(GetFocus())) return 0;
return handleKey(static_cast<int>(msg->wParam)) ? 1 : 0;
}
accelerator_register_t g_accel{translateAccel, true, nullptr};
bool g_accelRegistered = false;
void registerAccel() {
if (g_accelRegistered || !g_rec) return;
g_rec->Register("accelerator", &g_accel);
g_accelRegistered = true;
}
void unregisterAccel() {
if (!g_accelRegistered || !g_rec) return;
g_rec->Register("-accelerator", &g_accel);
g_accelRegistered = false;
}
} // namespace reasampler::panel
// --- Public API (the timer + tail read seam — panel_input.h) -------------------
namespace reasampler {
void bankPanelNotifyProjectLoaded() {
// Persist restored a project's membership + active mode this tick (main.cpp calls
// this from the same consumeLoadSignal() branch that reapplies the active mode).
// Arm the new-content detector to re-baseline on its next tick so the just-loaded
// project's pre-existing content is treated as the baseline (nothing new) rather
// than diffed against the previous project and mass-tagged into the active mode.
// A flag (not an inline reset) because detectNewContent owns the baseline and runs
// later in the SAME OnTimer tick — it drains this and re-baselines against the live
// set in one place, keeping the reset and the observe() adjacent and ordered.
panel::g_panel.reloadPending = true;
}
void bankPanelRefresh() {
// New-content auto-tag detection runs EVERY tick regardless of panel open/close:
// tracks/items are created in the arrange view, not the panel, so detection must
// not be gated on the dock being visible. READ-ONLY on the project; only mutates
// the in-memory membership index (persist saves it like any action-driven tag).
const bool tagged = panel::detectNewContent();
// Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
// track can only newly become multi-mode when auto-tag placed content on it. Unlike
// the invisible membership tag above, minting is a visible structural mutation
// (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
// block and only mints for tracks that hold >1 mode's content — a single-mode track
// is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
if (tagged && panel::g_panel.session) {
ReaProject* proj = EnumProjects(-1, nullptr, 0);
mintManagedLanes(panel::g_panel.session->view(), proj);
}
if (!panel::g_panel.open || !panel::g_panel.hwnd) return;
// L5: the custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if a
// toolbar button has rested under the pointer past the delay, latch + repaint the tooltip.
panel::maybeShowTooltip();
if (panel::refreshFingerprint())
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
}
capture::TailSetting bankPanelTailSetting() {
// The authoritative setting lives in the session (session->tail()) so it travels
// inside the .rpp: it loads per project and saves with the project. This stays the
// read seam for the capture actions. manualMs is clamped here so a caller always
// receives a within-cap length regardless of what was stored/scrolled.
capture::TailSetting s = panel::currentTail();
s.manualMs = capture::clampManualMs(s.manualMs);
return s;
}
} // namespace reasampler