Files
reasampler/src/bank_panel.cpp
T

1718 lines
67 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <cstdint>
#include <cstdlib> // std::abs (drag threshold)
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
#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 <windows.h>
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
#else
#include <pthread.h>
#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<std::string, CachedThumbnail> 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<std::string> 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<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(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<const Bank*> namedBanks() {
std::vector<const Bank*> 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<std::int64_t>(lengthSec * srate);
if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; }
int frames = totalFrames > kMaxThumbnailFrames
? kMaxThumbnailFrames
: static_cast<int>(totalFrames);
std::vector<ReaSample> buf(static_cast<std::size_t>(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<std::size_t>(got) * nch;
std::vector<float> pcm(sampleCount);
for (std::size_t i = 0; i < sampleCount; ++i)
pcm[i] = static_cast<float>(buf[i]);
return computeEnvelope(pcm, static_cast<std::size_t>(nch),
static_cast<std::size_t>(got),
static_cast<std::size_t>(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<int>(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<int>(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<int>(bins[i].max * halfSpan);
int yMin = midY - static_cast<int>(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<int>(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<Mode>& modes = view.modes().all();
const int n = static_cast<int>(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<SegmentRect> 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<std::size_t>(i)];
const Mode& mode = modes[static_cast<std::size_t>(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<CellRect> 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<CellRect> rects =
computeCellRects(static_cast<int>(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<Sample>& samples = index->all();
const std::vector<CellRect> 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<int>(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<const Bank*> tabs = namedBanks();
const int n = static_cast<int>(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<TabRect> rects =
computeTabRects(strip, n, kTabSpec, g_panel.tabScroll);
for (const TabRect& tr : rects) {
const Bank* bk = tabs[static_cast<std::size_t>(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<const Bank*> 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<int>(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<Sample>& samples = index->all();
if (idx < 0 || idx >= static_cast<int>(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<int>(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<char> 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<int>(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<std::string>& 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<std::string> focusedSelectionIds() {
std::vector<std::string> ids;
const BankIndex* idx = indexForRegion(g_panel.focusedRegion);
if (!idx) return ids;
const std::vector<Sample>& samples = idx->all();
const int count = static_cast<int>(samples.size());
for (int i : g_panel.selection.indices)
if (i >= 0 && i < count) ids.push_back(samples[static_cast<std::size_t>(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<std::string> 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<Dest> 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<unsigned int>(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<unsigned int>(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<int>(kMenuMoveBase) &&
cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
} else if (cmd >= static_cast<int>(kMenuCopyBase) &&
cmd < static_cast<int>(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<const Bank*> tabs = namedBanks();
const int n = static_cast<int>(tabs.size());
const TabHit hit = hitTestTabStrip(x, y, strip, n, kTabSpec, g_panel.tabScroll);
if (hit.kind == TabHitKind::ScrollLeft || hit.kind == TabHitKind::ScrollRight) {
const TabStripLayout layout =
computeTabStripLayout(strip, n, kTabSpec, g_panel.tabScroll);
const int step = kTabSpec.tabWidth;
const int desired = g_panel.tabScroll +
(hit.kind == TabHitKind::ScrollLeft ? -step : step);
g_panel.tabScroll = clampTabScroll(desired, layout);
invalidatePanel();
return true;
}
if (hit.kind == TabHitKind::Tab) {
const Bank* bk = tabs[static_cast<std::size_t>(hit.index)];
if (bk->id != g_panel.shownBankId) {
g_panel.shownBankId = bk->id; // browse: show this bank's grid
g_panel.selection = Selection{}; // grid changed — reset selection
stopAudition();
}
g_panel.focusedRegion = Region::Banks;
invalidatePanel();
return true;
}
return false;
}
// Handles the pool region's full-height toggle. Returns true if consumed.
bool handlePoolChromeClick(int x, int y, const RECT& region) {
const RECT ftb = fullHtBtnRect(region);
if (x >= ftb.left && x < ftb.right && y >= ftb.top && y < ftb.bottom) {
bankPanelToggledPoolFullHeight();
return true;
}
return false;
}
// Applies a left-click at (x, y): route to 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<Mode>& modes = g_panel.session->view().modes().all();
if (seg < static_cast<int>(modes.size())) {
applyMode(g_panel.session->view(),
modes[static_cast<std::size_t>(seg)].id, nullptr);
invalidatePanel();
}
return;
}
}
// 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<CellRect> rects = regionCellRects(region, isBanks, index);
const int hit = hitTestCell(x, y, rects);
const int count = index ? static_cast<int>(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<int>(msg->wParam)) ? 1 : 0;
}
accelerator_register_t g_accel{translateAccel, true, nullptr};
bool g_accelRegistered = false;
void registerAccel() {
if (g_accelRegistered || !g_rec) return;
g_rec->Register("accelerator", &g_accel);
g_accelRegistered = true;
}
void unregisterAccel() {
if (!g_accelRegistered || !g_rec) return;
g_rec->Register("-accelerator", &g_accel);
g_accelRegistered = false;
}
// --- 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<const Bank*> tabs = namedBanks();
const TabHit hit = hitTestTabStrip(x, y, strip,
static_cast<int>(tabs.size()), kTabSpec,
g_panel.tabScroll);
if (hit.kind == TabHitKind::Tab) {
g_panel.dropKind = DropKind::Tab;
g_panel.dropBankId = tabs[static_cast<std::size_t>(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<int>(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<const Bank*> tabs = namedBanks();
const TabHit hit = hitTestTabStrip(x, y, strip,
static_cast<int>(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<std::size_t>(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<std::string> 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