// 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 "bank_book.h" #include "bank_grid.h" #include "bank_model.h" #include "capture_paths.h" #include "mode_switch.h" #include "peaks.h" #include "persist.h" #include "tab_strip.h" #include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure) #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires // 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_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy #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 ----------------------------------------------------- TailSetting tail; // --- Audition preview ----------------------------------------------------- preview_register_t preview{}; PCM_source* previewSrc = nullptr; bool previewActive = false; bool previewInited = false; }; 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); int yMax = midY - static_cast(bins[i].max * halfSpan); int yMin = midY - static_cast(bins[i].min * halfSpan); 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; } 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(g_panel.tail); 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); } // --- 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; } // --- Audition preview (unchanged from M5) ------------------------------------- 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; persistBook(); 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; } persistBook(); 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; persistBook(); // 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; persistBook(); invalidatePanel(); } void doActivateBank(const std::string& bankId) { if (!book()) return; if (!book()->setActiveBank(bankId)) return; // rejects an unknown id persistBook(); 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). 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; for (const std::string& sid : sampleIds) { if (copy) book()->copySample(sid, srcBankId, destBankId); else book()->moveSample(sid, srcBankId, destBankId); } persistBook(); // 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(); } // 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, 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}); HMENU menu = CreatePopupMenu(); if (dests.empty()) { menuAppend(menu, kMenuNone, "No other bank to move to", /*grayed=*/true); TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); return; } const std::string label = std::to_string(sel.size()) + (sel.size() == 1 ? " sample" : " samples"); 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()); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); 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 cycles the tail mode. const RECT f = panelFooter(w, h); if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) { g_panel.tail.mode = cycleTailMode(g_panel.tail.mode); 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(); } // 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; 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_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 bankPanelRefresh() { if (!g_panel.open || !g_panel.hwnd) return; if (refreshFingerprint()) InvalidateRect(g_panel.hwnd, nullptr, FALSE); } TailSetting bankPanelTailSetting() { TailSetting s = g_panel.tail; 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