// bank_panel.cpp — REAPER-facing docked grid (M5 Wave A/B + Phase B4). See // bank_panel.h. // // 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). // // What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit // tested): // * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via // DockWindowRemove; toggled open/closed. // * WM_PAINT: a VERTICAL SPLIT (Phase B4) — the pool grid region on top, a // LICE-drawn named-banks tab-page region below (one tab per named bank, an // overflow/scroll strip), and two full-height toggles that collapse the split. // Each region reuses the M5 grid render loop (waveform thumbnails / empty state). // * per-sample PCM read via PCM_source fed to peaks::computeEnvelope at cell width. // * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation). // * id-keyed bank management (create / rename / delete / evacuate / activate) and // sample move/copy — driven from a tab context menu and a drag — against the B1 // BankBook model on g_session.book(), persisted via g_session.saveToActiveProject(). // // READ-ONLY of the TIMELINE (load-bearing principle): this panel never inserts into // the arrange. It DOES mutate the bank BOOK (create/rename/move/etc.) — that is the // whole point of B4 — but only the index/model + ext-state, never the arrange, never // a sample file on disk (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). // // THE PURE SEAMS: grid tiling / hit-test / selection math live in bank_grid; the // mode-switch geometry in mode_switch; the named-banks TAB-STRIP layout, overflow/ // scroll, and hit-test in tab_strip. All three are unit-tested outside the DAW; only // draw + input routing + the model calls live here. // // REFERENCE-INVALIDATION GUARDRAIL (CONTEXT.md §Multi-bank): a bank-structural // mutation (create/delete/evacuate/activate/move) can reallocate the book's vector, // so a BankIndex& / Bank* must NEVER be cached across one. Every handler below // resolves fresh AFTER any mutation and passes bank IDS (not references) into the // model ops. #include "bank_panel.h" #include #include // std::abs (drag threshold) #include #include #include #include #include #include #include "actions.h" // persistBankOp — shared undo-block wrapper (R-B panel path) #include "bank_book.h" #include "bank_grid.h" #include "bank_model.h" #include "capture_paths.h" #include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2) #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 "mode_switch.h" #include "peaks.h" #include "persist.h" #include "tab_strip.h" #include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) #include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires #include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) // SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); // on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). #ifdef _WIN32 #include #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) #else #include #endif #include "wdltypes.h" #include "swell/swell.h" #include "lice/lice.h" #include "resource.h" #include "reaper_plugin.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_DockWindowAddEx #define REAPERAPI_WANT_DockWindowActivate #define REAPERAPI_WANT_DockWindowRemove #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy // 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_WANT_CountTracks #define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_GetTrackMediaItem // Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h): // PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the // STOCK symbols (not SWS-only) — see the audition section below. #define REAPERAPI_WANT_PlayPreview #define REAPERAPI_WANT_StopPreview #define REAPERAPI_WANT_GetUserInputs #define REAPERAPI_WANT_ShowMessageBox #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString #include "reaper_plugin_functions.h" // main.cpp owns the module instance handle and REAPER's dispatch struct. extern REAPER_PLUGIN_HINSTANCE g_hInst; extern reaper_plugin_info_t* g_rec; namespace reasampler { namespace { namespace fs = std::filesystem; // --- Layout / palette constants ---------------------------------------------- const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); const LICE_pixel kColCellBg = LICE_RGBA(44, 44, 48, 255); const LICE_pixel kColCellBorder = LICE_RGBA(70, 70, 76, 255); const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255); const LICE_pixel kColSelBg = LICE_RGBA(38, 66, 58, 255); const LICE_pixel kColSelBorder = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColFocusBorder = LICE_RGBA(210, 230, 220, 255); // --- Mode-switch header (D5) -------------------------------------------------- constexpr int kHeaderHeight = 30; const LICE_pixel kColHeaderBg = LICE_RGBA(20, 20, 22, 255); const LICE_pixel kColSegBg = LICE_RGBA(44, 44, 48, 255); const LICE_pixel kColSegActiveBg = LICE_RGBA(58, 96, 84, 255); const LICE_pixel kColSegBorder = LICE_RGBA(70, 70, 76, 255); const COLORREF kRgbSegText = RGB(170, 170, 176); const COLORREF kRgbSegActiveText = RGB(220, 235, 228); // --- Tail-mode footer (T1 exposure) ------------------------------------------- constexpr int kFooterHeight = 26; const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); const COLORREF kRgbFooterText = RGB(190, 205, 198); // --- Vertical split + region headers + tab strip (Phase B4) ------------------- // // The client area, top to bottom: mode-switch header (kHeaderHeight) | split body | // tail footer (kFooterHeight). The split body holds the pool region (top) and the // named-banks region (bottom). Each region opens with a REGION HEADER band: a title, // the active-bank readout, and a full-height toggle button. The named-banks region's // header ALSO hosts the LICE tab strip and a "+" create button. constexpr int kRegionHeaderHeight = 24; // per-region title/toggle band constexpr int kTabStripHeight = 26; // the named-banks tab strip band constexpr int kSplitDividerHeight = 3; // the horizontal divider between regions constexpr int kFullHtBtnWidth = 22; // the square full-height toggle button constexpr int kCreateBtnWidth = 22; // the "+" create-bank button // Tab strip metrics (the pure tab_strip owns the math; these are its inputs). const TabStripSpec kTabSpec{/*tabWidth=*/96, /*chevronWidth=*/20}; const LICE_pixel kColRegionHeaderBg = LICE_RGBA(24, 24, 26, 255); const LICE_pixel kColRegionBorder = LICE_RGBA(70, 70, 76, 255); const LICE_pixel kColDivider = LICE_RGBA(12, 12, 14, 255); const LICE_pixel kColBtnBg = LICE_RGBA(48, 48, 52, 255); const LICE_pixel kColBtnBorder = LICE_RGBA(90, 90, 96, 255); const LICE_pixel kColTabBg = LICE_RGBA(40, 40, 44, 255); const LICE_pixel kColTabShownBg = LICE_RGBA(58, 58, 64, 255); // the shown tab (browsed) const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255); // the ACTIVE bank (capture target) const LICE_pixel kColTabBorder = LICE_RGBA(70, 70, 76, 255); const LICE_pixel kColTabActiveBorder= LICE_RGBA(150, 230, 190, 255);// active-tab accent const LICE_pixel kColChevronBg = LICE_RGBA(32, 32, 36, 255); // Drop-target highlight during a drag (unmistakable accent over the destination). const LICE_pixel kColDropTarget = LICE_RGBA(90, 150, 120, 255); const COLORREF kRgbRegionTitle = RGB(200, 205, 210); const COLORREF kRgbActiveReadout = RGB(150, 230, 190); // "Active: …" accent const COLORREF kRgbTabText = RGB(200, 200, 205); const COLORREF kRgbTabActiveText = RGB(230, 245, 238); const COLORREF kRgbBtnText = RGB(210, 215, 220); // --- Panel state -------------------------------------------------------------- struct CachedThumbnail { Envelope envelope; int width = 0; }; // Which of the two split regions currently owns the selection / receives keyboard // input. The move/copy source is the focused region's displayed bank. enum class Region { Pool, Banks }; // What a drag is dropping onto, resolved live under the pointer during a drag. enum class DropKind { None, PoolRegion, Tab }; struct PanelState { ReaSamplerSession* session = nullptr; HWND hwnd = nullptr; bool open = false; std::string bankFingerprint; std::uint64_t generation = 0; std::unordered_map cache; // --- Selection (per focused region) --------------------------------------- // One live selection, scoped to `focusedRegion`. Switching regions moves the // selection with the focus (a click in the other region reseeds it there). Selection selection; int selItemCount = 0; Region focusedRegion = Region::Pool; // --- Vertical-split state ------------------------------------------------- BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; // The named bank whose grid the banks region shows (the SHOWN tab) — DISTINCT // from the active/capture-target bank (book().activeBankId()). Empty when there // are no named banks. Reconciled each fingerprint pass so it always names a live // named bank (or is empty). std::string shownBankId; // Tab-strip horizontal scroll offset (px), clamped to the strip's max each frame. int tabScroll = 0; // --- Drag (sample move between regions/onto a tab) ------------------------ // A drag begins only after the pointer moves past a threshold from a press that // landed on a SELECTED grid cell — this is how it is disambiguated from the M5 // multi-select drag (which begins immediately on any grid press). See onLBtnDown/ // onMouseMove. dragging is true once the threshold is crossed. bool dragArmed = false; // pressed on a selected cell; watching for threshold bool dragging = false; // threshold crossed; a move-drag is in progress int dragStartX = 0, dragStartY = 0; Region dragSourceRegion = Region::Pool; std::string dragSourceBankId; // the bank the dragged samples come from std::vector dragSampleIds;// snapshot of the selection at drag start DropKind dropKind = DropKind::None; // live drop target under the pointer std::string dropBankId; // destination bank id when dropKind==Tab // --- Tail-mode toggle ----------------------------------------------------- // 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, // restores it on project load). The panel reads it for drawing and mutates it via // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the // project dirty so the choice saves. bankPanelTailSetting is the read seam for the // capture actions. Held here only through the session pointer above. // --- Audition preview ----------------------------------------------------- preview_register_t preview{}; PCM_source* previewSrc = nullptr; bool previewActive = false; bool previewInited = false; // guards double init / deinit // --- New-content detection (D2 Wave 2) ------------------------------------ // // Each timer tick diffs the live track+item GUID set against the previous tick to // auto-tag content created SINCE the last tick into the then-active mode. The // baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its // first observe()) so pre-existing content is never mass-tagged (it stays Arrange). // // Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a // pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact // tick persist restores a project's membership + active mode (the same tick it // reapplies the active mode); that sets reloadPending so the NEXT detect tick this // same tick re-baselines against the fully-loaded set and reports nothing new. This // replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than // persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto // a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then // diffed against the previous project's stale baseline and were mass-tagged into the // active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the // two identity checks agree by construction. // // Lives for the extension's lifetime alongside the session, independent of panel // open/close — detection must run whether or not the dock is visible (content is // created in the arrange, not the panel). GuidBaseline contentBaseline; bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick }; PanelState g_panel; void stopAudition(); // --- Current-project directory (mirrors persist.cpp's derivation) ------------- std::string currentProjectDir() { std::vector buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); std::string rpp(buf.data()); if (rpp.empty()) return {}; return normalizeSlashes(fs::path(rpp).parent_path().string()); } // --- Book / bank accessors ---------------------------------------------------- BankBook* book() { return g_panel.session ? &g_panel.session->book() : nullptr; } // The BankIndex a region currently displays. Pool region -> the pool; banks region -> // the shown tab's bank (or nullptr when no named banks / the id went stale). Resolved // FRESH every call (never cached across a mutation). const BankIndex* indexForRegion(Region r) { BankBook* b = book(); if (!b) return nullptr; if (r == Region::Pool) return &b->pool().index; if (g_panel.shownBankId.empty()) return nullptr; return b->index(g_panel.shownBankId); } // The bank id a region displays (pool id, or the shown tab's id; "" when none). std::string bankIdForRegion(Region r) { if (r == Region::Pool) return std::string(kPoolBankId); return g_panel.shownBankId; } // The named banks in ordinal order (pool excluded) — the tabs. Resolved fresh. std::vector namedBanks() { std::vector out; BankBook* b = book(); if (!b) return out; for (const Bank& bk : b->banks()) if (!bk.isPool()) out.push_back(&bk); return out; } // --- Thumbnail computation (unchanged from M5) -------------------------------- Envelope computeThumbnail(const std::string& absPath, int width) { if (width <= 0 || absPath.empty()) return {}; PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); if (!src) return {}; const int nch = src->GetNumChannels(); const double srate = src->GetSampleRate(); const double lengthSec = src->GetLength(); if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { PCM_Source_Destroy(src); return {}; } std::int64_t totalFrames = static_cast(lengthSec * srate); if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } int frames = totalFrames > kMaxThumbnailFrames ? kMaxThumbnailFrames : static_cast(totalFrames); std::vector buf(static_cast(frames) * nch, 0.0); PCM_source_transfer_t block{}; block.time_s = 0.0; block.samplerate = srate; block.nch = nch; block.length = frames; block.samples = buf.data(); block.samples_out = 0; src->GetSamples(&block); PCM_Source_Destroy(src); const int got = block.samples_out; if (got <= 0) return {}; const std::size_t sampleCount = static_cast(got) * nch; std::vector pcm(sampleCount); for (std::size_t i = 0; i < sampleCount; ++i) pcm[i] = static_cast(buf[i]); return computeEnvelope(pcm, static_cast(nch), static_cast(got), static_cast(width)); } const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir) { ThumbnailKey key{sample.id, width, g_panel.generation}; const std::string ks = thumbnailKeyString(key); auto it = g_panel.cache.find(ks); if (it != g_panel.cache.end()) return it->second.envelope; const std::string abs = resolveBankFile(projectDir, sample.relativePath); CachedThumbnail thumb; thumb.width = width; thumb.envelope = computeThumbnail(abs, width); auto ins = g_panel.cache.emplace(ks, std::move(thumb)); return ins.first->second.envelope; } // --- Drawing: thumbnails (unchanged from M5) ---------------------------------- void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env, bool selected, bool focused) { const LICE_pixel bg = selected ? kColSelBg : kColCellBg; LICE_pixel border = selected ? kColSelBorder : kColCellBorder; if (focused) border = kColFocusBorder; LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, bg, 1.0f, 0); LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, border, 1.0f, 0); if (focused) LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, border, 1.0f, 0); if (env.empty()) { const int midY = rect.y + rect.height / 2; LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, kColMidline, 1.0f, 0, false); return; } const int channels = static_cast(env.size()); const int bandH = rect.height / channels; for (int ch = 0; ch < channels; ++ch) { const ChannelEnvelope& bins = env[ch]; const int bandTop = rect.y + ch * bandH; const int midY = bandTop + bandH / 2; const double halfSpan = (bandH / 2) - 2; LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, kColMidline, 1.0f, 0, false); const int nbins = static_cast(bins.size()); if (nbins <= 0) continue; const int innerW = rect.width - 4; for (int i = 0; i < nbins; ++i) { const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0); // min<=max always (peaks invariant). Draw a vertical line from the // min sample to the max sample, clamped to the band. int yMax = midY - static_cast(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up int yMin = midY - static_cast(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down if (yMax < bandTop) yMax = bandTop; if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1; LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false); } } } // Draws a centered single-line label into a rect (COLORREF text). void drawCenteredText(LICE_IBitmap* bmp, const RECT& rc, const char* text, COLORREF color, UINT fmt) { HDC dc = bmp->getDC(); if (!dc) return; RECT r = rc; SetTextColor(dc, color); SetBkMode(dc, TRANSPARENT); DrawText(dc, text, -1, &r, fmt | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } // --- Mode-switch header (D5, unchanged) --------------------------------------- HeaderRect panelHeader(int w) { return HeaderRect{0, 0, w, kHeaderHeight}; } int modeCount() { if (!g_panel.session) return 0; return static_cast(g_panel.session->view().modes().size()); } void drawModeSwitch(LICE_IBitmap* bmp, int w) { if (!g_panel.session) return; const ViewModeModel& view = g_panel.session->view(); const std::vector& modes = view.modes().all(); const int n = static_cast(modes.size()); LICE_FillRect(bmp, 0, 0, w, kHeaderHeight, kColHeaderBg, 1.0f, 0); if (n <= 0) return; const HeaderRect header = panelHeader(w); const std::vector segs = computeSegmentRects(header, n); if (segs.empty()) return; const std::string& activeId = view.activeModeId(); HDC dc = bmp->getDC(); for (int i = 0; i < n; ++i) { const SegmentRect& s = segs[static_cast(i)]; const Mode& mode = modes[static_cast(i)]; const bool active = mode.id == activeId; LICE_FillRect(bmp, s.x, s.y, s.width, s.height, active ? kColSegActiveBg : kColSegBg, 1.0f, 0); LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, kColSegBorder, 1.0f, 0); if (!dc) continue; RECT rc{s.x, s.y, s.x + s.width, s.y + s.height}; SetTextColor(dc, active ? kRgbSegActiveText : kRgbSegText); SetBkMode(dc, TRANSPARENT); DrawText(dc, mode.displayName.c_str(), -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } } // --- Tail-mode footer (T1, unchanged) ----------------------------------------- RECT panelFooter(int w, int h) { RECT rc{}; rc.left = 0; rc.right = w; rc.top = h - kFooterHeight; rc.bottom = h; if (rc.top < kHeaderHeight) rc.top = rc.bottom; return rc; } // 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{}; } // Draws the tail-mode toggle into the footer strip: a filled band, a top divider, // and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure // tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it. void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0); LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false); HDC dc = bmp->getDC(); if (!dc) return; const std::string label = tailToggleLabel(currentTail()); RECT rc = f; rc.left += 8; SetTextColor(dc, kRgbFooterText); SetBkMode(dc, TRANSPARENT); DrawText(dc, label.c_str(), -1, &rc, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. // Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) // so both agree on the hit target. bool pointInFooter(int x, int y) { if (!g_panel.hwnd) return false; RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; } // 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); } // --- Split geometry ----------------------------------------------------------- // // Every rect below is derived from the client size + fullHeight state, and BOTH paint // and hit-testing call these so they never drift. All are top-left origin. // The body band between the mode-switch header and the tail footer. RECT splitBody(int w, int h) { RECT rc{}; rc.left = 0; rc.right = w; rc.top = kHeaderHeight; const RECT footer = panelFooter(w, h); rc.bottom = (footer.top < footer.bottom) ? footer.top : h; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } // True when both regions are shown (the split is live). Otherwise one region fills // the body. bool poolShown() { return g_panel.fullHeight != BankPanelFullHeight::BanksOnly; } bool banksShown() { return g_panel.fullHeight != BankPanelFullHeight::PoolOnly; } // The pool region's rect (whole-region: header band + grid). Empty when hidden. RECT poolRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!poolShown()) return RECT{0, 0, 0, 0}; if (!banksShown()) return body; // pool full-height: the whole body // Split: pool gets the top half (minus the divider). RECT rc = body; rc.bottom = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; if (rc.bottom < rc.top) rc.bottom = rc.top; return rc; } // The named-banks region's rect (whole-region: header band + tab strip + grid). RECT banksRegionRect(int w, int h) { const RECT body = splitBody(w, h); if (!banksShown()) return RECT{0, 0, 0, 0}; if (!poolShown()) return body; // banks full-height: the whole body RECT rc = body; rc.top = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2 + kSplitDividerHeight; if (rc.top > rc.bottom) rc.top = rc.bottom; return rc; } // A region's header band (the top kRegionHeaderHeight of the region). RECT regionHeaderRect(const RECT& region) { RECT rc = region; rc.bottom = region.top + kRegionHeaderHeight; if (rc.bottom > region.bottom) rc.bottom = region.bottom; return rc; } // The named-banks region's tab strip (below its header band). TabStripRect banksTabStripRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); TabStripRect s; s.x = region.left; s.y = hdr.bottom; s.width = region.right - region.left; s.height = kTabStripHeight; if (s.y + s.height > region.bottom) s.height = region.bottom - s.y; if (s.height < 0) s.height = 0; return s; } // A region's grid viewport (below the header band, and below the tab strip for the // banks region). This is where cells tile. RECT regionGridRect(const RECT& region, bool isBanks) { RECT rc = region; rc.top = region.top + kRegionHeaderHeight; if (isBanks) rc.top += kTabStripHeight; if (rc.top > rc.bottom) rc.top = rc.bottom; return rc; } // The full-height toggle button rect inside a region header (right-aligned). RECT fullHtBtnRect(const RECT& region) { const RECT hdr = regionHeaderRect(region); RECT rc = hdr; rc.right = hdr.right - 4; rc.left = rc.right - kFullHtBtnWidth; rc.top = hdr.top + 2; rc.bottom = hdr.bottom - 2; return rc; } // The "+" create-bank button rect inside the named-banks region header (left of the // full-height button). RECT createBtnRect(const RECT& region) { RECT ft = fullHtBtnRect(region); RECT rc = ft; rc.right = ft.left - 4; rc.left = rc.right - kCreateBtnWidth; return rc; } // The cell rects for a region's grid, translated into the region's grid viewport. // Both paint and hit-testing call this. Empty when the index is null/empty. std::vector regionCellRects(const RECT& region, bool isBanks, const BankIndex* index) { if (!index || index->empty()) return {}; const RECT grid = regionGridRect(region, isBanks); const int w = grid.right - grid.left; if (w <= 0) return {}; std::vector rects = computeCellRects(static_cast(index->size()), w, kGrid); for (CellRect& r : rects) { r.x += grid.left; r.y += grid.top; } return rects; } // --- Drawing: a grid region --------------------------------------------------- // Draws one region's grid of thumbnails (or an empty-state line) clipped to its // viewport. `selectionOwner` is true when this region holds the live selection, so // its cells show selection/focus chrome; the other region draws plain. void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks, const BankIndex* index, const std::string& emptyMsg, bool selectionOwner, const std::string& projectDir) { const RECT grid = regionGridRect(region, isBanks); if (grid.bottom <= grid.top) return; if (!index || index->empty()) { drawCenteredText(bmp, grid, emptyMsg.c_str(), RGB(150, 150, 156), DT_CENTER); return; } const std::vector& samples = index->all(); const std::vector rects = regionCellRects(region, isBanks, index); const int binWidth = kGrid.cellWidth - 4; for (std::size_t i = 0; i < rects.size(); ++i) { const CellRect& rect = rects[i]; if (rect.y >= grid.bottom) continue; // below the viewport: skip (no scroll) const int idx = static_cast(i); const bool selected = selectionOwner && g_panel.selection.contains(idx); const bool focused = selectionOwner && g_panel.selection.focus == idx; const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir); drawThumbnail(bmp, rect, env, selected, focused); } } // Draws a region header: title, the active-bank readout, and the full-height button. void drawRegionHeader(LICE_IBitmap* bmp, const RECT& region, const char* title, const std::string& activeName, bool poolBtnIsPool) { const RECT hdr = regionHeaderRect(region); LICE_FillRect(bmp, hdr.left, hdr.top, hdr.right - hdr.left, hdr.bottom - hdr.top, kColRegionHeaderBg, 1.0f, 0); LICE_Line(bmp, hdr.left, hdr.bottom - 1, hdr.right, hdr.bottom - 1, kColRegionBorder, 1.0f, 0, false); // Title, left. RECT titleRc = hdr; titleRc.left += 8; titleRc.right = titleRc.left + 120; drawCenteredText(bmp, titleRc, title, kRgbRegionTitle, DT_LEFT); // Active-bank readout, centered — the UNMISTAKABLE indicator (settled B4 // constraint). It names the active/capture-target bank in an accent color in // BOTH region headers, so the active bank is legible even when it is not the // shown tab and even when it is the pool (no tab exists for it). const std::string readout = "Active: " + activeName; RECT actRc = hdr; actRc.left = titleRc.right + 6; actRc.right = createBtnRect(region).left - 6; if (actRc.right > actRc.left) drawCenteredText(bmp, actRc, readout.c_str(), kRgbActiveReadout, DT_LEFT); // Full-height toggle button: an arrow glyph. In split it means "maximize this // region"; when this region is already full it means "restore the split". const RECT btn = fullHtBtnRect(region); LICE_FillRect(bmp, btn.left, btn.top, btn.right - btn.left, btn.bottom - btn.top, kColBtnBg, 1.0f, 0); LICE_DrawRect(bmp, btn.left, btn.top, btn.right - btn.left, btn.bottom - btn.top, kColBtnBorder, 1.0f, 0); const bool thisFull = poolBtnIsPool ? (g_panel.fullHeight == BankPanelFullHeight::PoolOnly) : (g_panel.fullHeight == BankPanelFullHeight::BanksOnly); drawCenteredText(bmp, btn, thisFull ? "\xE2\x87\x85" : "\xE2\x87\x83", // ⇅ / ⇃ kRgbBtnText, DT_CENTER); } // Draws the named-banks tab strip: one tab per named bank (ordinal order), the SHOWN // tab highlighted, the ACTIVE bank's tab lit with the accent border, overflow // chevrons when present, plus the "+" create button in the header. During a drag, // the tab under the pointer gets the drop-target highlight. void drawTabStrip(LICE_IBitmap* bmp, const RECT& region) { const TabStripRect strip = banksTabStripRect(region); if (strip.height <= 0) return; LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height, kColRegionHeaderBg, 1.0f, 0); const std::vector tabs = namedBanks(); const int n = static_cast(tabs.size()); if (n == 0) { RECT r{strip.x + 8, strip.y, strip.x + strip.width, strip.y + strip.height}; drawCenteredText(bmp, r, "No named banks — click + to create one.", RGB(140, 140, 146), DT_LEFT); return; } const TabStripLayout layout = computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll); // Chevrons (drawn first so tabs sit above their inner edges). if (layout.overflow) { LICE_FillRect(bmp, strip.x, strip.y, kTabSpec.chevronWidth, strip.height, kColChevronBg, 1.0f, 0); LICE_FillRect(bmp, strip.x + strip.width - kTabSpec.chevronWidth, strip.y, kTabSpec.chevronWidth, strip.height, kColChevronBg, 1.0f, 0); RECT lc{strip.x, strip.y, strip.x + kTabSpec.chevronWidth, strip.y + strip.height}; RECT rc{strip.x + strip.width - kTabSpec.chevronWidth, strip.y, strip.x + strip.width, strip.y + strip.height}; drawCenteredText(bmp, lc, "\xE2\x80\xB9", kRgbTabText, DT_CENTER); // ‹ drawCenteredText(bmp, rc, "\xE2\x80\xBA", kRgbTabText, DT_CENTER); // › } const std::string activeId = book() ? book()->activeBankId() : std::string(); const std::vector rects = computeTabRects(strip, n, kTabSpec, g_panel.tabScroll); for (const TabRect& tr : rects) { const Bank* bk = tabs[static_cast(tr.index)]; const bool shown = bk->id == g_panel.shownBankId; const bool active = bk->id == activeId; const bool dropHere = g_panel.dragging && g_panel.dropKind == DropKind::Tab && g_panel.dropBankId == bk->id; LICE_pixel bg = shown ? kColTabShownBg : kColTabBg; if (active) bg = kColTabActiveBg; if (dropHere) bg = kColDropTarget; LICE_FillRect(bmp, tr.x, tr.y, tr.width, tr.height, bg, 1.0f, 0); // The active bank's tab gets a bright accent border (unmistakable), distinct // from the shown tab's fill highlight — active ≠ shown, made visible. LICE_DrawRect(bmp, tr.x, tr.y, tr.width, tr.height, active ? kColTabActiveBorder : kColTabBorder, 1.0f, 0); if (active) LICE_DrawRect(bmp, tr.x + 1, tr.y + 1, tr.width - 2, tr.height - 2, kColTabActiveBorder, 1.0f, 0); RECT lr{tr.x + 4, tr.y, tr.x + tr.width - 4, tr.y + tr.height}; drawCenteredText(bmp, lr, bk->displayName.c_str(), active ? kRgbTabActiveText : kRgbTabText, DT_CENTER); } } // The active bank's display name (for the readout). "Pool" when the pool is active. std::string activeBankName() { BankBook* b = book(); if (!b) return std::string(kPoolBankName); const Bank* bk = b->bank(b->activeBankId()); return bk ? bk->displayName : std::string(kPoolBankName); } // --- Full paint --------------------------------------------------------------- void paintPanel(HWND hwnd, HDC hdc) { RECT cr{}; GetClientRect(hwnd, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; if (w <= 0 || h <= 0) return; LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, kColBackground); const std::string projectDir = currentProjectDir(); const std::string activeName = activeBankName(); // Pool region (top). if (poolShown()) { const RECT region = poolRegionRect(w, h); drawRegionHeader(&bmp, region, "Pool", activeName, /*poolBtnIsPool=*/true); drawRegionGrid(&bmp, region, /*isBanks=*/false, indexForRegion(Region::Pool), "No samples in the pool yet. Capture one to see it here.", g_panel.focusedRegion == Region::Pool, projectDir); // Drop-target highlight for the pool region during a drag. if (g_panel.dragging && g_panel.dropKind == DropKind::PoolRegion) { const RECT grid = regionGridRect(region, false); LICE_DrawRect(&bmp, grid.left + 1, grid.top + 1, grid.right - grid.left - 2, grid.bottom - grid.top - 2, kColDropTarget, 1.0f, 0); } } // Split divider. if (poolShown() && banksShown()) { const RECT body = splitBody(w, h); const int dy = body.top + (body.bottom - body.top - kSplitDividerHeight) / 2; LICE_FillRect(&bmp, 0, dy, w, kSplitDividerHeight, kColDivider, 1.0f, 0); } // Named-banks region (bottom). if (banksShown()) { const RECT region = banksRegionRect(w, h); drawRegionHeader(&bmp, region, "Banks", activeName, /*poolBtnIsPool=*/false); // "+" create button (drawn as part of the banks header). const RECT cbtn = createBtnRect(region); LICE_FillRect(&bmp, cbtn.left, cbtn.top, cbtn.right - cbtn.left, cbtn.bottom - cbtn.top, kColBtnBg, 1.0f, 0); LICE_DrawRect(&bmp, cbtn.left, cbtn.top, cbtn.right - cbtn.left, cbtn.bottom - cbtn.top, kColBtnBorder, 1.0f, 0); drawCenteredText(&bmp, cbtn, "+", kRgbBtnText, DT_CENTER); drawTabStrip(&bmp, region); drawRegionGrid(&bmp, region, /*isBanks=*/true, indexForRegion(Region::Banks), g_panel.shownBankId.empty() ? "Select or create a named bank." : "This bank is empty. Move samples here from the pool.", g_panel.focusedRegion == Region::Banks, projectDir); } drawModeSwitch(&bmp, w); drawTailFooter(&bmp, w, h); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } // --- Bank-change detection ---------------------------------------------------- // A fingerprint of the WHOLE BOOK: for each bank, its id + display name + active flag // + per-sample id/path. Catches every mutation the panel must redraw for: capture, // project load, and B4's own create/rename/delete/move/activate. std::string bookFingerprint() { BankBook* b = book(); if (!b) return {}; std::string fp = std::to_string(b->size()); fp += '\x1e'; fp += b->activeBankId(); for (const Bank& bk : b->banks()) { fp += '\x1d'; fp += bk.id; fp += '\x1c'; fp += bk.displayName; for (const Sample& s : bk.index.all()) { fp += '\x1f'; fp += s.id; fp += '\x1f'; fp += s.relativePath; } } return fp; } // Reconciles shownBankId against the live named banks: keep it if it still names a // named bank; otherwise fall to the first named bank (or empty when none). Keeps the // banks region always showing a valid tab. Never touches the ACTIVE bank. void reconcileShownBank() { BankBook* b = book(); if (!b) { g_panel.shownBankId.clear(); return; } if (!g_panel.shownBankId.empty()) { const Bank* bk = b->bank(g_panel.shownBankId); if (bk && !bk->isPool()) return; // still valid } const std::vector named = namedBanks(); g_panel.shownBankId = named.empty() ? std::string() : named.front()->id; } bool refreshFingerprint() { if (!book()) return false; std::string fp = bookFingerprint(); if (fp == g_panel.bankFingerprint) return false; g_panel.bankFingerprint = std::move(fp); ++g_panel.generation; g_panel.cache.clear(); // The selection indexes into the OLD order; a change can invalidate those, so // clear it and stop any audition. if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) { g_panel.selection = Selection{}; stopAudition(); } reconcileShownBank(); const BankIndex* idx = indexForRegion(g_panel.focusedRegion); g_panel.selItemCount = idx ? static_cast(idx->size()) : 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(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. // // 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& allGuids, std::map& itemOnManualLane) { 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); 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); } } } // 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 live; std::map itemOnManualLane; enumerateLiveGuids(proj, live, itemOnManualLane); const std::vector added = g_panel.contentBaseline.observe(live); if (added.empty()) return false; // first poll after open, or nothing new this tick // 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 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 { newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption } } ViewModeModel& model = g_panel.session->view(); const std::vector tags = autoTagNewContent(newTracks, newItems, model.activeModeId()); for (const AutoTag& tag : tags) model.membership().tag(tag.guid, tag.modeId); return !tags.empty(); } // --- Audition preview --------------------------------------------------------- // // READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW // playback only. It NEVER inserts into the arrange, creates items/tracks, or // mutates the project or bank. PlayPreview streams a caller-owned PCM_source // through REAPER's preview bus and touches nothing in the project. // // FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by // signature/struct, not semantics — DAW-verify): // 1. REAPER's audio thread reads the preview_register_t by POINTER while the // preview is active (the struct's own comment mandates a cs/mutex we init), // so the register must outlive playback — we hold it in g_panel (static), // never on the stack. // 2. StopPreview is assumed to detach the source from the audio thread BEFORE it // returns, making it safe to PCM_Source_Destroy the source immediately after. // This is the conventional contract (SWS' preview helpers rely on it) but is // NOT documented in the header — flagged. If a rare race surfaced, the fix is // a StartPreviewFade + deferred free; not done now (YAGNI, no evidence). // 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not // set mono (&1024). volume 1.0, loop false, curpos 0. void initPreview() { if (g_panel.previewInited) return; #ifdef _WIN32 InitializeCriticalSection(&g_panel.preview.cs); #else pthread_mutex_init(&g_panel.preview.mutex, nullptr); #endif g_panel.previewInited = true; } void stopAudition() { if (g_panel.previewActive) { StopPreview(&g_panel.preview); g_panel.previewActive = false; } if (g_panel.previewSrc) { PCM_Source_Destroy(g_panel.previewSrc); g_panel.previewSrc = nullptr; } g_panel.preview.src = nullptr; } void deinitPreview() { if (!g_panel.previewInited) return; #ifdef _WIN32 DeleteCriticalSection(&g_panel.preview.cs); #else pthread_mutex_destroy(&g_panel.preview.mutex); #endif g_panel.previewInited = false; } // Auditions sample `idx` of the FOCUSED region's displayed bank. void startAudition(int idx) { stopAudition(); const BankIndex* index = indexForRegion(g_panel.focusedRegion); if (!index) return; const std::vector& samples = index->all(); if (idx < 0 || idx >= static_cast(samples.size())) return; const std::string projectDir = currentProjectDir(); const std::string abs = resolveBankFile(projectDir, samples[idx].relativePath); if (abs.empty()) return; PCM_source* src = PCM_Source_CreateFromFile(abs.c_str()); if (!src) return; g_panel.preview.src = src; g_panel.preview.m_out_chan = 0; g_panel.preview.curpos = 0.0; g_panel.preview.loop = false; g_panel.preview.volume = 1.0; g_panel.preview.peakvol[0] = 0.0; g_panel.preview.peakvol[1] = 0.0; g_panel.preview.preview_track = nullptr; if (PlayPreview(&g_panel.preview) != 0) { g_panel.previewSrc = src; g_panel.previewActive = true; } else { PCM_Source_Destroy(src); g_panel.preview.src = nullptr; } } // --- Input helpers ------------------------------------------------------------ bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; } bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; } void invalidatePanel() { if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } // The item count of the focused region's bank (0 when none). int focusedItemCount() { const BankIndex* idx = indexForRegion(g_panel.focusedRegion); return idx ? static_cast(idx->size()) : 0; } // Which region (if any) contains client point (x, y); returns false via `out` set to // Pool by default when the point is in neither region body. bool regionAt(int x, int y, Region& out) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; if (poolShown()) { const RECT r = poolRegionRect(w, h); if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { out = Region::Pool; return true; } } if (banksShown()) { const RECT r = banksRegionRect(w, h); if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) { out = Region::Banks; return true; } } return false; } // --- Bank management ops (id-keyed; drive the B1 model + persist) -------------- // // Each op mutates g_session.book() then persists via saveToActiveProject(). After a // STRUCTURAL mutation (create/delete/evacuate) any Bank*/BankIndex& is invalid — we // resolve fresh, pass ids, and let the next refreshFingerprint repaint. persistBook // no-ops on an unsaved project (matches the capture/B3 quiet-persist idiom). void persistBook() { if (g_panel.session) g_panel.session->saveToActiveProject(); } // REAPER's stock single-line input (comma-safe via the \x1f return separator, as B3). bool promptText(const char* title, const char* caption, const std::string& initial, std::string& out) { std::vector buf(512, '\0'); std::snprintf(buf.data(), buf.size(), "%s", initial.c_str()); const std::string captions = std::string(caption) + ",separator=\x1f"; if (!GetUserInputs(title, 1, captions.c_str(), buf.data(), static_cast(buf.size()))) return false; std::string s(buf.data()); if (s.empty()) return false; out = std::move(s); return true; } // Mints a genuine REAPER GUID string as a stable bank id (same as B3 mintBankId). std::string mintBankId() { GUID g{}; genGuid(&g); char buf[64] = {0}; guidToString(&g, buf); return std::string(buf); } void doCreateBank() { if (!book()) return; std::string name; if (!promptText("ReaSampler: create bank", "Bank name:", "", name)) return; const std::string id = mintBankId(); if (!book()->createBank(id, name)) { ShowMessageBox("A bank with that name already exists.", "ReaSampler: create bank", 0); return; } g_panel.shownBankId = id; // show the freshly-created bank g_panel.focusedRegion = Region::Banks; persistBankOp("ReaSampler: create bank"); invalidatePanel(); } void doRenameBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::string current = bk->displayName; // copy before any mutation std::string newName; if (!promptText("ReaSampler: rename bank", "New name:", current, newName)) return; if (!book()->renameBank(bankId, newName)) { ShowMessageBox("Another bank already uses that name.", "ReaSampler: rename bank", 0); return; } persistBankOp("ReaSampler: rename bank"); invalidatePanel(); } // Delete with the RICHER confirm-on-non-empty affordance (B4): the confirm names the // member count AND offers evacuate as the one-click alternative (Yes=delete anyway, // No=evacuate-then-keep, Cancel=abort) — richer than B3's basic YESNO. void doDeleteBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const std::size_t members = bk->index.size(); // read BEFORE any mutation const std::string name = bk->displayName; if (members > 0) { const std::string msg = "\"" + name + "\" holds " + std::to_string(members) + (members == 1 ? " sample" : " samples") + ".\n\nYes — delete the bank AND drop its samples (files are kept on disk " "but no bank references them until prune).\nNo — Evacuate them to the " "pool first, then delete the empty bank (keeps the samples).\nCancel — " "do nothing."; // 3 == MB_YESNOCANCEL. 6=Yes, 7=No, 2=Cancel (SDK). const int r = ShowMessageBox(msg.c_str(), "ReaSampler: delete non-empty bank", 3); if (r == 2) return; // Cancel if (r == 7) { // No -> evacuate, then delete empty if (!book()->evacuate(bankId)) return; // book() may have reallocated; re-resolve nothing (we pass the id again). } // r == 6 (Yes) falls through to a plain delete (drops members). } if (!book()->deleteBank(bankId)) return; persistBankOp("ReaSampler: delete bank"); // shownBankId is reconciled by the next fingerprint pass. If no named banks remain, // nudge focus to the pool so the selection has a valid home. if (namedBanks().empty()) g_panel.focusedRegion = Region::Pool; invalidatePanel(); } void doEvacuateBank(const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; if (!book()->evacuate(bankId)) return; persistBankOp("ReaSampler: evacuate bank"); invalidatePanel(); } void doActivateBank(const std::string& bankId) { if (!book()) return; if (!book()->setActiveBank(bankId)) return; // rejects an unknown id persistBankOp("ReaSampler: activate bank"); invalidatePanel(); } // Move or copy `sampleIds` from `srcBankId` to `destBankId` (index-only). Both pass // ids straight to the model op (no BankIndex& cached across the loop's mutations). // // NO-OP GUARDRAIL — VERB-AWARE (matches the action layer's doBankTransferSelected): // * MOVE collapse: the source entry WAS removed (bank_book removes unconditionally // before the dest add collapses on hash), so the index DID mutate — counts. // * COPY collapse: the source is left intact AND the dest already held the hash, // so NOTHING changed — a true index no-op. Must NOT open an undo point. // Hence: copy counts only real gains (Copied); move counts gains OR collapses. void transferSamples(const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { if (!book()) return; if (sampleIds.empty() || srcBankId == destBankId) return; if (!book()->bank(srcBankId) || !book()->bank(destBankId)) return; int ok = 0, collapsed = 0; for (const std::string& sid : sampleIds) { const TransferResult r = copy ? book()->copySample(sid, srcBankId, destBankId) : book()->moveSample(sid, srcBankId, destBankId); switch (r) { case TransferResult::Moved: case TransferResult::Copied: ++ok; break; case TransferResult::Collapsed: ++collapsed; break; default: break; } } const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); if (!mutated) return; // nothing changed — no persist, no undo point const char* label = copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)"; persistBankOp(label); // The selection indexed into the source; after a move those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } // Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to // the file: a last-reference remove leaves the file on disk, orphaned until Phase R // prune — remove NEVER deletes bytes (the manifest is untouched). Confirm-on-last- // reference guardrail: a single confirm summarizing the N whose files this would orphan // (computed on the PRE-mutation reference graph), fired only when at least one would // orphan. Ids passed by value — no BankIndex& cached across the loop's mutations. void removeSamples(const std::vector& sampleIds, const std::string& srcBankId) { if (!book() || sampleIds.empty()) return; const Bank* src = book()->bank(srcBankId); if (!src) return; // Count files this remove would orphan — computed BEFORE mutating, so a same-hash // sibling in another bank counts as a surviving reference. int orphanCount = 0; for (const std::string& sid : sampleIds) { const Sample* s = src->index.query(sid); if (!s) continue; // already gone; not a last-reference orphan if (!book()->hashReferencedElsewhere(s->contentHash, srcBankId)) ++orphanCount; } if (orphanCount > 0) { const std::string msg = std::to_string(orphanCount) + (orphanCount == 1 ? " selected sample is" : " selected samples are") + " in no other bank.\n\nRemoving " + (orphanCount == 1 ? "it" : "them") + " drops the index entry only — the file stays on disk until you prune " "(it is never deleted by remove).\n\nRemove anyway?"; // 4 == MB_YESNO. 6=Yes (SDK); anything else cancels. const int r = ShowMessageBox(msg.c_str(), "ReaSampler: remove last-reference sample(s)", 4); if (r != 6) return; } int removed = 0; for (const std::string& sid : sampleIds) if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == RemoveResult::Removed) ++removed; if (removed == 0) return; // nothing changed — no persist, no undo point persistBankOp("ReaSampler: remove sample(s)"); // The selection indexed into the source; after a remove those indices are stale, so // clear it (the fingerprint pass will also clear, but do it now for immediacy). g_panel.selection = Selection{}; invalidatePanel(); } // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { std::vector ids; const BankIndex* idx = indexForRegion(g_panel.focusedRegion); if (!idx) return ids; const std::vector& samples = idx->all(); const int count = static_cast(samples.size()); for (int i : g_panel.selection.indices) if (i >= 0 && i < count) ids.push_back(samples[static_cast(i)].id); return ids; } // --- Popup menus -------------------------------------------------------------- // // SWELL/Win32 both expose CreatePopupMenu / InsertMenu (SWELL aliases SWELL_InsertMenu // -> InsertMenu) / TrackPopupMenu(TPM_RETURNCMD) / DestroyMenu. We build a menu of // (label -> small int command), track it at screen coords, and switch on the return. // Menu command ids are LOCAL to the popup (not REAPER action ids) — TPM_RETURNCMD // hands the chosen id straight back, so no hookcommand routing is involved. // Appends a string item (id) to `menu` at its end. Portable over Win32/SWELL: both // accept InsertMenu(menu, pos, MF_BYPOSITION|MF_STRING, id, text) with a negative // position appending. Win32 and SWELL both treat pos < 0 as an append. void menuAppend(HMENU menu, unsigned int id, const char* text, bool grayed = false) { UINT flags = MF_BYPOSITION | MF_STRING; if (grayed) flags |= MF_GRAYED; InsertMenu(menu, -1, flags, id, text); } void menuSeparator(HMENU menu) { InsertMenu(menu, -1, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr); } // Menu command ids (local to a popup). enum : unsigned int { kMenuNone = 0, kMenuActivate = 100, kMenuRename, kMenuDelete, kMenuEvacuate, kMenuCreate, kMenuRemove, // remove selected sample(s) from the source bank (B5) kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; // Shows the right-click context menu for a named-bank TAB: activate / rename / delete // / evacuate that bank, plus a create entry. Drives the id-keyed ops. void showTabMenu(int screenX, int screenY, const std::string& bankId) { if (!book()) return; const Bank* bk = book()->bank(bankId); if (!bk || bk->isPool()) return; const bool isActive = book()->activeBankId() == bankId; const bool nonEmpty = !bk->index.empty(); HMENU menu = CreatePopupMenu(); menuAppend(menu, kMenuActivate, isActive ? "Active (capture target)" : "Activate (make capture target)", /*grayed=*/isActive); menuSeparator(menu); menuAppend(menu, kMenuRename, "Rename\xE2\x80\xA6"); menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty); menuAppend(menu, kMenuDelete, "Delete\xE2\x80\xA6"); menuSeparator(menu); menuAppend(menu, kMenuCreate, "New bank\xE2\x80\xA6"); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); switch (cmd) { case kMenuActivate: doActivateBank(bankId); break; case kMenuRename: doRenameBank(bankId); break; case kMenuEvacuate: doEvacuateBank(bankId); break; case kMenuDelete: doDeleteBank(bankId); break; case kMenuCreate: doCreateBank(); break; default: break; } } // Shows the move/copy menu for the current selection (the SOURCE is the focused // region's bank). Lists every OTHER bank (pool + named) as a move destination, then a // copy submenu-free flat list (copy entries follow the move block). Move is the // default (listed first); copy is the deliberate secondary act. void showSelectionMenu(int screenX, int screenY) { const std::vector sel = focusedSelectionIds(); if (sel.empty()) return; const std::string srcId = bankIdForRegion(g_panel.focusedRegion); // Destinations: pool + named banks, excluding the source. Ordinal order. struct Dest { std::string id; std::string name; }; std::vector dests; if (srcId != std::string(kPoolBankId)) dests.push_back({std::string(kPoolBankId), std::string(kPoolBankName)}); for (const Bank* bk : namedBanks()) if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); const std::string label = std::to_string(sel.size()) + (sel.size() == 1 ? " sample" : " samples"); HMENU menu = CreatePopupMenu(); // Move/copy blocks appear only when there is another bank to transfer to; Remove is // always offered (it needs no destination — it drops the entry from the source). if (!dests.empty()) { menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuMoveBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); for (std::size_t i = 0; i < dests.size(); ++i) menuAppend(menu, kMenuCopyBase + static_cast(i), (" " + dests[i].name).c_str()); menuSeparator(menu); } menuAppend(menu, kMenuRemove, ("Remove " + label + "\xE2\x80\xA6").c_str()); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); if (cmd == static_cast(kMenuRemove)) { removeSamples(sel, srcId); } else if (cmd >= static_cast(kMenuMoveBase) && cmd < static_cast(kMenuMoveBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); } else if (cmd >= static_cast(kMenuCopyBase) && cmd < static_cast(kMenuCopyBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuCopyBase].id, /*copy=*/true); } } // --- 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 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; } // Applies a left-click at (x, y): route to mode switch / footer / region chrome / // grid selection, and arm a potential drag when the click lands on a selected cell. 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; // Mode-switch header (D5) takes precedence. if (g_panel.session) { const int seg = hitTestSegment(x, y, panelHeader(w), modeCount()); if (seg >= 0) { const std::vector& modes = g_panel.session->view().modes().all(); if (seg < static_cast(modes.size())) { applyMode(g_panel.session->view(), modes[static_cast(seg)].id, nullptr); invalidatePanel(); } return; } } // Tail footer: a click anywhere in the bottom strip cycles the tail mode // (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail // setting (which the capture actions read and persist saves with the project) and // marks the project dirty so the choice travels inside the .rpp — it touches // NOTHING in the bank/arrange. Checked before the grid so a footer click never selects. if (g_panel.session && pointInFooter(x, y)) { TailSetting& tail = g_panel.session->tail(); tail.mode = cycleTailMode(tail.mode); markTailDirty(); invalidatePanel(); 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); const BankIndex* index = indexForRegion(reg); const std::vector rects = regionCellRects(region, isBanks, index); const int hit = hitTestCell(x, y, rects); const int count = index ? static_cast(index->size()) : 0; // 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; } // If the pressed cell is already SELECTED and no modifier is held, arm a drag — // the actual selection change is deferred to LBUTTONUP if no drag begins (so a // plain click on a multi-selection can start a drag without collapsing it first). // Otherwise apply the click immediately. This is the M5-multi-select-drag // disambiguation: M5 has no cell drag; a drag here begins only from a selected // cell past a movement threshold (see onMouseMove), so plain click/shift/ctrl // multi-select is untouched. const bool onSelected = g_panel.selection.contains(hit); if (onSelected && !ctrlDown() && !shiftDown()) { g_panel.dragArmed = true; g_panel.dragStartX = x; g_panel.dragStartY = y; g_panel.dragSourceRegion = reg; // Keep the current (multi-)selection as the drag payload candidate. g_panel.selection.focus = hit; // move the caret to the pressed cell 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; } // The column count for a region's current grid width (nav needs the layout's wrap). int columnsForRegion(Region reg) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; const RECT region = reg == Region::Banks ? banksRegionRect(w, h) : poolRegionRect(w, h); const RECT grid = regionGridRect(region, reg == Region::Banks); return columnsForWidth(grid.right - grid.left, kGrid); } 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). Same confirm-on-last-reference // path the context-menu "Remove" uses; 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; 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; } // --- Drag (move between regions/onto a tab) ----------------------------------- constexpr int kDragThreshold = 5; // px the pointer must move to begin a drag // Resolves the drop target under client (x, y) during a drag, updating dropKind / // dropBankId. A drop onto the pool region -> the pool; onto a named tab -> that bank; // anywhere else -> none. void updateDropTarget(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { g_panel.dropKind = DropKind::Tab; g_panel.dropBankId = tabs[static_cast(hit.index)]->id; return; } } if (poolShown()) { const RECT pr = poolRegionRect(w, h); const RECT grid = regionGridRect(pr, false); if (x >= grid.left && x < grid.right && y >= grid.top && y < grid.bottom) { g_panel.dropKind = DropKind::PoolRegion; return; } } } void onMouseMove(int x, int y) { if (g_panel.dragArmed && !g_panel.dragging) { if (std::abs(x - g_panel.dragStartX) > kDragThreshold || std::abs(y - g_panel.dragStartY) > kDragThreshold) { // Threshold crossed — begin the drag. Snapshot the payload NOW. g_panel.dragging = true; g_panel.dragSourceBankId = bankIdForRegion(g_panel.dragSourceRegion); g_panel.dragSampleIds = focusedSelectionIds(); SetCapture(g_panel.hwnd); } } if (g_panel.dragging) { updateDropTarget(x, y); invalidatePanel(); } } // Commits (or abandons) a drag on button-up. A drop onto a DIFFERENT bank moves the // dragged samples there; a drop onto the source bank / dead space is a no-op. Ctrl // held at drop = copy (the deliberate secondary), else move. void onLBtnUp(int x, int y) { if (g_panel.dragging) { updateDropTarget(x, y); std::string destId; if (g_panel.dropKind == DropKind::PoolRegion) destId = std::string(kPoolBankId); else if (g_panel.dropKind == DropKind::Tab) destId = g_panel.dropBankId; if (!destId.empty() && destId != g_panel.dragSourceBankId && !g_panel.dragSampleIds.empty()) { transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId, /*copy=*/ctrlDown()); } if (GetCapture() == g_panel.hwnd) ReleaseCapture(); } else if (g_panel.dragArmed) { // Press-release on a selected cell with no drag: treat as a plain click that // collapses the multi-selection to the pressed cell (standard behavior). const BankIndex* idx = indexForRegion(g_panel.focusedRegion); const int count = idx ? static_cast(idx->size()) : 0; const int focus = g_panel.selection.focus; if (focus >= 0) g_panel.selection = applyClick(g_panel.selection, focus, false, false, count); } g_panel.dragArmed = false; g_panel.dragging = false; g_panel.dropKind = DropKind::None; g_panel.dropBankId.clear(); invalidatePanel(); } // A right-click: on a named tab -> the tab management menu; on a grid cell of the // focused region with a selection -> the move/copy menu. void handleRightClick(int x, int y) { RECT cr{}; GetClientRect(g_panel.hwnd, &cr); const int w = cr.right - cr.left, h = cr.bottom - cr.top; // Tab management menu. if (banksShown()) { const RECT br = banksRegionRect(w, h); const TabStripRect strip = banksTabStripRect(br); const std::vector tabs = namedBanks(); const TabHit hit = hitTestTabStrip(x, y, strip, static_cast(tabs.size()), kTabSpec, g_panel.tabScroll); if (hit.kind == TabHitKind::Tab) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showTabMenu(pt.x, pt.y, tabs[static_cast(hit.index)]->id); return; } } // Grid selection menu (move/copy). Only when the right-click lands in the focused // region's grid and there is a selection. Region reg = Region::Pool; if (regionAt(x, y, reg) && reg == g_panel.focusedRegion && !g_panel.selection.empty()) { POINT pt{x, y}; ClientToScreen(g_panel.hwnd, &pt); showSelectionMenu(pt.x, pt.y); } } // --- Dialog proc + docking ---------------------------------------------------- WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); paintPanel(hwnd, hdc); EndPaint(hwnd, &ps); return 0; } case WM_LBUTTONDOWN: { SetFocus(hwnd); handleClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; } case WM_MOUSEMOVE: onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_LBUTTONUP: onLBtnUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_RBUTTONDOWN: SetFocus(hwnd); handleRightClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_CAPTURECHANGED: // Capture lost before a drag began (e.g. pointer left window pre-threshold // and button released outside) — disarm so the state doesn't stay stale. if (g_panel.dragArmed && !g_panel.dragging) { g_panel.dragArmed = false; invalidatePanel(); } return 0; case WM_MOUSEWHEEL: { // Fine-adjust the Manual tail length when the wheel is over the footer. // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so // convert to client space before hit-testing the footer. The signed wheel // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer // handler acts, so scrolling elsewhere in the dock still behaves normally. POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; ScreenToClient(hwnd, &pt); const int delta = static_cast(HIWORD(wParam)); return handleWheel(pt.x, pt.y, delta) ? 1 : 0; } case WM_DESTROY: if (GetCapture() == hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; g_panel.hwnd = nullptr; g_panel.open = false; return 0; default: break; } return 0; } void openPanel() { if (g_panel.open && g_panel.hwnd) { DockWindowActivate(g_panel.hwnd); return; } initPreview(); g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), GetMainHwnd(), dlgProc, 0); if (!g_panel.hwnd) return; DockWindowAddEx(g_panel.hwnd, "ReaSampler Bank", "reasampler_bank_panel", true); DockWindowActivate(g_panel.hwnd); g_panel.open = true; registerAccel(); reconcileShownBank(); refreshFingerprint(); } void closePanel() { if (GetCapture() == g_panel.hwnd) ReleaseCapture(); stopAudition(); g_panel.selection = Selection{}; g_panel.dragArmed = g_panel.dragging = false; unregisterAccel(); if (g_panel.hwnd) { DockWindowRemove(g_panel.hwnd); DestroyWindow(g_panel.hwnd); g_panel.hwnd = nullptr; } g_panel.open = false; } } // namespace // --- Public API --------------------------------------------------------------- void bankPanelInit(ReaSamplerSession* session) { g_panel.session = session; } void bankPanelToggle() { if (g_panel.open) closePanel(); else openPanel(); } bool bankPanelIsOpen() { return g_panel.open; } std::vector bankPanelSelectedSampleIds() { return focusedSelectionIds(); } std::string bankPanelSelectedSourceBankId() { // The focused region's displayed bank is the move/copy source. Default to the // pool (a safe source) when nothing is selected / the panel never opened. if (g_panel.selection.empty()) return std::string(kPoolBankId); const std::string id = bankIdForRegion(g_panel.focusedRegion); return id.empty() ? std::string(kPoolBankId) : id; } 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. 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 = 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 && g_panel.session) { ReaProject* proj = EnumProjects(-1, nullptr, 0); mintManagedLanes(g_panel.session->view(), proj); } if (!g_panel.open || !g_panel.hwnd) return; if (refreshFingerprint()) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } 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. TailSetting s = currentTail(); s.manualMs = clampManualMs(s.manualMs); return s; } BankPanelFullHeight bankPanelFullHeight() { return g_panel.fullHeight; } static void setFullHeight(BankPanelFullHeight target) { g_panel.fullHeight = (g_panel.fullHeight == target) ? BankPanelFullHeight::Split : target; if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } void bankPanelToggledPoolFullHeight() { setFullHeight(BankPanelFullHeight::PoolOnly); } void bankPanelToggledBanksFullHeight() { setFullHeight(BankPanelFullHeight::BanksOnly); } void bankPanelShutdown() { closePanel(); deinitPreview(); g_panel.cache.clear(); g_panel.session = nullptr; } } // namespace reasampler