// panel_input.cpp — the input + detection seam of the docked bank panel: left-click / // wheel / keyboard routing, accelerator registration, tail-setting read/mutate helpers, // and timer-driven new-content auto-tag detection. Mouse-MOVE lives in panel_drag; the // fingerprint pass lives in panel_thumbnails (it owns the cache it invalidates). // Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp // owns the API pointers). DAW-verified, not unit-tested. #include #include #include #include #include #include "shell/panel/panel_state.h" #include "shell/panel/panel_input.h" #include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch #include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam #include "shell/capture/track_guid.h" // guidString — canonical track GUID key #include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch // New-content detection: 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{}; } // The registered activate action behind a footer mode segment, or 0 if the mode has none. // Routing the segment through the SAME action the Actions list fires is what gives a // panel-initiated switch the persist + repaint it used to skip; a mode with no such // action (the model is N-mode, the UI ships two) resolves to 0 here, which // modeSegmentEnabled reads as unroutable so the segment paints dead rather than live- // but-inert. Non-anonymous: panel_render.cpp resolves the same id to compute that bool. int modeActivateCommandId(const std::string& modeId) { ActionBarRow row{}; if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE"; else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN"; else return 0; return resolveBarCommandId(row); } namespace { // Commits the current tail setting to ext state and marks the active project dirty so the // change travels inside the .rpp on Ctrl+S — closes the gap where toggle/scroll would dirty // the project but never write the new value. No-ops cleanly on an unsaved project. 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 via Main_OnCommand. Returns true // iff the click was inside the bar band, so the caller stops before grid handling. bool handleToolbarClick(int x, int y, const ActionBarRect& bar, const std::vector& 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(hit)]; // A disabled button (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; } // True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp; // reproduced locally so this file stays self-contained. constexpr int kFreeModeFixedLanes = 2; bool isFixedLaneTrack(MediaTrack* tr) { return static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; } // Enumerates the live project's track + item GUIDs. Fills `allGuids` and, per item, // whether it sits on a manual lane (exempt from auto-tag). `trackItemGuids` maps each // track to its item GUIDs so a newly-detected item's PRE-EXISTING siblings resolve in // one lookup. Manual-lane classification uses the single pure predicate isOnManualLane. void enumerateLiveGuids(ReaProject* proj, std::set& allGuids, std::map& itemOnManualLane, std::map>& 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& 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); // "" for a normal track — isOnManualLane returns false immediately for a // non-fixed-lane track 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: REAPER exposes no "item/track added" callback, so this diffs live // GUIDs against the baseline and auto-tags the new ones into the active mode. Called every // timer tick regardless of panel open/close; the enumeration's throttle and its correctness // argument are the gate immediately below. READ-ONLY on the project; mutates only the // in-memory membership index — deliberately OUTSIDE any Undo block (auto-tag is a // background metadata update, not a destructive edit; an Undo block here would flood // REAPER's history with an entry per tick that sees new content). // // Returns true iff this tick tagged at least one new GUID — the signal the caller uses to // decide whether to run the lane-minting pass. bool detectNewContent() { if (!g_panel.session) return false; // Throttles the enumeration (O(T+I) REAPER calls + allocations) to kDetectIntervalMs via // elapsedAtLeast (core/util; wraparound-safe against GetTickCount()'s rollover, see its // header). A skipped tick leaves reloadPending/the baseline untouched, so the guards below // still run before the NEXT diff whenever this gate next opens — only the diff's cadence // changes, not its correctness. // // KNOWN CONSEQUENCE, not new: a new item's mode resolves from // preExistingTrackModes()/activeModeId() at ENUMERATION time (below), not creation time, so // switching mode or moving the item before the next tick can land it in a different mode // than a tighter cadence would — the window existed at ~33 ms; now widened ~16x. const unsigned int now = GetTickCount(); if (!elapsedAtLeast(now, g_panel.lastDetectTick, kDetectIntervalMs)) return false; g_panel.lastDetectTick = now; ReaProject* proj = EnumProjects(-1, nullptr, 0); // A project (re)load re-arms the first-poll guard so we never diff across two // projects. bankPanelNotifyProjectLoaded() sets reloadPending on the tick persist // restores membership + active mode; draining it here re-baselines against the // fully-loaded set, so pre-existing untagged tracks stay Arrange rather than getting // mass-tagged. Using persist's load signal (not a local ReaProject* compare) is what // fixes the reload-mis-tag bug: pointer identity can recycle across projects. if (g_panel.reloadPending) { g_panel.contentBaseline.reset(); g_panel.reloadPending = false; } std::set live; std::map itemOnManualLane; std::map> trackItemGuids; enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); std::vector 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(); // An explicit tag wins: this detector classifies content the USER made, not // content the tool made and already classified. added.erase(std::remove_if(added.begin(), added.end(), [&](const std::string& g) { return model.membership().query(g) != nullptr; }), added.end()); // 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 newItemGuids = [&] { std::set 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 trackOfItem; for (const auto& [trackGuid, items] : trackItemGuids) for (const std::string& ig : items) trackOfItem[ig] = trackGuid; // The distinct modes the PRE-EXISTING, managed-eligible items on `trackGuid` resolve // to. Untagged siblings default to Arrange; new siblings excluded; manual-lane // siblings EXEMPT — matching planLaneMinting's own-item mode span computation. const auto preExistingTrackModes = [&](const std::string& trackGuid) -> std::set { std::set 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 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 in the item-lane map is an item; // otherwise it's a track. std::vector newTracks; std::vector 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 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. Occupied count == index size by // construction, so the raw index size IS the dense selection-space extent. int focusedItemCount() { const BankModel* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; } // 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) { const RECT ftb = fullHtBtnRect(region); if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) { bankPanelToggledBanksFullHeight(); return true; } 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 tabs = namedBanks(); const int n = static_cast(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(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; } } // namespace // 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. 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, 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). { 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). 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 ViewModeModel& view = g_panel.session->view(); const std::vector& modes = view.modes().all(); if (seg < static_cast(modes.size())) { const std::string& id = modes[static_cast(seg)].id; const bool isActive = id == view.activeModeId(); const int cmd = modeActivateCommandId(id); // Disabled/dead rationale: core/ui/footer_bar.h. Claimed but inert, the // same shape a disabled toolbar row takes — never falls through to the grid. if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) { if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); } } return; } const FooterBarLayout fb = footerBarLayoutFor(w, h); if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) { // Cycles None -> Auto -> Manual -> None; touches NOTHING in the bank/arrange. TailSetting& tail = g_panel.session->tail(); tail.mode = cycleTailMode(tail.mode); markTailDirty(); invalidatePanel(); return; } // Fires through its registered command id (not the session directly) so the panel // affordance and the bindable action share the one guarded dry-run/confirm/delete // path in doBankPruneFolder. const ButtonRect pb = pruneButtonRectFor(w, h); if (hitTestPruneButton(x, y, pb)) { const int cmd = bankPruneCommandId(); if (cmd != 0) Main_OnCommand(cmd, 0); return; } } if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return; 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; } } 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); // 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). 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 defers 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; only // the caret moves immediately); unselected cell applies the plain-click selection now // (collapses to the single pressed cell) so a press-and-drag works without a prior // selecting click and focusedSelectionIds() resolves the right payload once the // threshold is crossed. ctrl/shift presses are selection-only — no drag arm. 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 still delivered once the pointer leaves the // client rect before the drag threshold is crossed — without capture, a fast // straight-out drag never transitions dragArmed -> dragging. Released on button-up or // WM_CAPTURECHANGED (which 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(); } // 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]. Otherwise does nothing (returns false so the caller can let REAPER/the // docker handle the wheel normally). 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; } namespace { 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. Silent; a no-op when nothing is selected. const std::vector 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(msg->wParam)) ? 1 : 0; } accelerator_register_t g_accel{translateAccel, true, nullptr}; bool g_accelRegistered = false; } // namespace 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 namespace reasampler { void bankPanelNotifyProjectLoaded() { // Arms the new-content detector to re-baseline on its next ENUMERATING tick (the flag // persists across any throttled/skipped ticks in between) so the just-loaded project's // pre-existing content is the baseline (nothing new) rather than diffed against the // previous project and mass-tagged. A flag, not an inline reset, because detectNewContent // owns the baseline and drains this before its own diff. 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); detectNewContent (panel_input.cpp, // above) is the authoritative comment for its throttle, correctness argument, and // read-only/mutation contract. const bool tagged = panel::detectNewContent(); // Lane minting 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. Managed lanes only. 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; // Transport transitions are not ours to cause and REAPER offers no change callback, // so the mode segments' disabled state is polled here and repainted only on an edge. const bool blocked = transportBlocksModeSwitch(nullptr); if (blocked != panel::g_panel.modeSwitchBlocked) { panel::g_panel.modeSwitchBlocked = blocked; InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); } // 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 it. panel::maybeShowTooltip(); if (panel::refreshFingerprint()) InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE); } capture::TailSetting bankPanelTailSetting() { // The authoritative setting lives in the session so it travels inside the .rpp; this // is 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