Files
reasampler/src/bank_panel.cpp
T
daniel fc54472d3e feat(bank_panel): audition, multi-select, and keyboard nav (M5 Wave B)
Pure bank_grid gains hit-test, selection-update, and arrow-nav math with
tests; the panel wires mouse multi-select, keyboard nav via an accelerator
hook, and stock PlayPreview/StopPreview audition with a leak-free preview
lifecycle. Read-only: no arrange insertion, no project/bank mutation.
2026-07-22 21:55:16 -04:00

797 lines
34 KiB
C++

// bank_panel.cpp — REAPER-facing docked grid (M5, Wave A). 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: draw the current bank as a grid of waveform thumbnails using LICE,
// or a centered empty-state string when the bank is empty.
// * per-sample PCM read via PCM_source (PCM_Source_CreateFromFile +
// PCM_source::GetSamples) fed to peaks::computeEnvelope at the cell width.
// * an in-memory thumbnail cache keyed by (sample id, draw width, bank
// generation) so paint does not recompute envelopes every frame.
//
// READ-ONLY (load-bearing principle): this panel never inserts into the arrange
// and never mutates the project or the bank. It only reads g_session.bank() and
// reads sample files off disk.
//
// THUMBNAIL-CACHE DECISION (CONTEXT.md §Open questions "recompute vs store peak
// bins alongside the index"): for Wave A we RECOMPUTE into an in-memory cache and
// do NOT persist peak bins in the index. Rationale: the persisted index stays
// lean and format-stable; envelopes are cheap to recompute on demand and must be
// recomputed anyway whenever the panel width (bin count) changes, which a stored
// fixed-resolution bin set could not satisfy. Storing bins is a later optimization
// if profiling shows recompute cost matters (it is bounded: one read + one O(frames)
// pass per sample, only on cache miss).
#include "bank_panel.h"
#include <cstdint>
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
#include "bank_grid.h"
#include "bank_model.h"
#include "capture_paths.h"
#include "peaks.h"
#include "persist.h"
// 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).
// LICE routes its GDI through whichever backend is active. wdltypes.h gives
// WDL_DLGRET (the platform dialog-proc return type).
#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"
// reaper_plugin.h defines preview_register_t (the stock preview struct) and the
// REAPER_PLUGIN_HINSTANCE / registration types. main.cpp includes it with
// REAPERAPI_IMPLEMENT; here we only need the type declarations.
#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
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
// STOCK symbols (not SWS-only) — see the audition section below.
#define REAPERAPI_WANT_PlayPreview
#define REAPERAPI_WANT_StopPreview
#include "reaper_plugin_functions.h"
// main.cpp owns the module instance handle (needed to load the dialog resource)
// and REAPER's dispatch struct (needed to register the keyboard accelerator hook).
extern REAPER_PLUGIN_HINSTANCE g_hInst;
extern reaper_plugin_info_t* g_rec;
namespace reasampler {
namespace {
namespace fs = std::filesystem;
// --- Layout / palette constants (Wave A: fixed, no user config — YAGNI) -------
// Cell size + spacing for the grid. Tuned for a legible thumbnail at a glance;
// revisit when audition/selection UI lands (Wave B) and cells gain chrome.
const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10};
// How many PCM frames to pull per sample for the thumbnail. The envelope is drawn
// at cell width (~140 bins), so a few thousand frames per bin is ample; capping
// the read keeps a long sample's thumbnail cheap without a streaming loop. A
// captured one-shot/loop is short; a full-mix bounce is downsampled visually
// anyway. If a sample is longer than this, the thumbnail shows its head — an
// acceptable Wave-A approximation, flagged for Wave B (whole-file overview).
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 kColText = LICE_RGBA(200, 200, 205, 255);
// Selection chrome (Wave B). Selected cells get a tinted fill + brighter border;
// the focused cell (audition/nav target) gets a distinct accent border so it is
// distinguishable within a multi-selection.
const LICE_pixel kColSelBg = LICE_RGBA(38, 66, 58, 255); // selected fill tint
const LICE_pixel kColSelBorder = LICE_RGBA(120, 200, 160, 255);// selected border
const LICE_pixel kColFocusBorder = LICE_RGBA(210, 230, 220, 255);// focused-cell border
// --- Panel state --------------------------------------------------------------
// A computed thumbnail: the per-channel envelope at a known width. Held in the
// cache so paint reuses it until the sample, width, or bank generation changes.
struct CachedThumbnail {
Envelope envelope; // one ChannelEnvelope per channel, `width` bins each
int width = 0; // bins per channel this envelope was computed at
};
struct PanelState {
ReaSamplerSession* session = nullptr;
HWND hwnd = nullptr; // the docked dialog, null when closed
bool open = false;
// Bank-change detection: a cheap fingerprint of the bank (count + ids +
// relative paths). When it changes we bump `generation`, which invalidates
// every cache entry (keyed by generation) and forces a repaint. Simpler than
// adding a mutation counter to BankIndex, and correct across same-count
// project-load swaps (the fingerprint includes ids/paths, not just size).
std::string bankFingerprint;
std::uint64_t generation = 0;
// Thumbnail cache: key string (bank_grid::thumbnailKeyString) -> envelope.
// Entries for stale generations are lazily overwritten on next miss; a bank
// change also clears it wholesale (see refreshFingerprint) to bound memory.
std::unordered_map<std::string, CachedThumbnail> cache;
// --- Interaction (Wave B) -------------------------------------------------
// The current cell selection (indices into bank->all(), focus, anchor). Pure
// math lives in bank_grid; this holds the live state the pointer/keyboard
// mutate. A bank change (generation bump) resets it (indices could dangle).
Selection selection;
// The item count the selection was last validated against. On a bank change we
// clear the selection rather than risk indices pointing past the new count.
int selItemCount = 0;
// --- Audition preview (Wave B) --------------------------------------------
//
// The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex
// is initialized ONCE (initPreview) and destroyed ONCE (deinitPreview) across
// the panel's lifetime — NOT per playback — because REAPER's audio thread may
// touch the register's guarded fields. `previewSrc` is the PCM_source currently
// owned by `preview.src`; non-null exactly while auditioning. `previewActive`
// tracks whether PlayPreview succeeded and StopPreview is still owed.
preview_register_t preview{};
PCM_source* previewSrc = nullptr;
bool previewActive = false;
bool previewInited = false; // guards double init / deinit
};
PanelState g_panel;
// Forward declarations for the interaction/audition helpers defined lower down but
// referenced by earlier sections (e.g. refreshFingerprint stops audition on a bank
// change). Definitions live in the "Audition preview" / "Selection + input" blocks.
void stopAudition();
// --- Current-project directory (mirrors persist.cpp's derivation) -------------
//
// The index stores relative paths; resolving a bank file needs the current .rpp
// directory. persist.cpp derives this the same way for load; the panel is its own
// shell so it reads it directly rather than threading state through the session.
// FOLLOW-UP: capture.cpp, persist.cpp, and now bank_panel.cpp each carry this
// two-line derivation — a shared REAPER helper ("current project dir") is a clean
// small refactor once a third consumer exists (now it does). Out of scope this wave.
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 {}; // unsaved project: no resolvable bank
return normalizeSlashes(fs::path(rpp).parent_path().string());
}
// --- Thumbnail computation ----------------------------------------------------
// Reads up to kMaxThumbnailFrames of interleaved PCM from `absPath` and computes a
// per-channel min/max envelope at `width` bins. Returns an empty envelope on any
// failure (missing file, unreadable source, zero-length) — the caller draws an
// empty cell rather than propagating an error. READ-ONLY: opens the file through
// a PCM_source and destroys it; never touches the project.
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 {};
}
// Frames to read: the whole sample, capped so a long bounce stays cheap.
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);
// One GetSamples call filling a caller-allocated interleaved buffer. block.length
// is the requested frame count; samples_out reports what was actually rendered
// (may be short at end-of-file). We ask at the source's own rate so no resample.
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 {};
// ReaSample is double in some builds, float in others; peaks consumes float
// (peaks::Sample is a float alias, its native buffer type). Convert at this
// boundary — use `float` explicitly, NOT `reasampler::Sample`, because that
// name also denotes bank_model's metadata struct in this same namespace when
// both headers are visible (they are here in the module).
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));
}
// Returns the cached envelope for `sample` at `width`, computing+inserting it on a
// miss. Keyed by (id, width, current generation) so a resize or bank change misses
// and recomputes. `projectDir` resolves the sample's relative path to disk.
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 ------------------------------------------------------------------
// Draws one sample's envelope into `rect` of `bmp`: a cell background, border, a
// zero midline, and the min/max waveform. Multi-channel envelopes are stacked
// vertically (each channel gets an equal horizontal band) so a stereo sample shows
// both channels without folding (precision invariant: no stereo fold).
// `selected` tints the fill and brightens the border; `focused` overrides the
// border with the accent color so the caret cell reads within a multi-selection.
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);
// The focused cell gets a second inset rectangle so it stays distinct even when
// its neighbors are also selected (double outline reads as "the active one").
if (focused)
LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2,
border, 1.0f, 0);
if (env.empty()) {
// Unreadable / empty sample: cell drawn, no waveform. A single midline
// signals "cell present, no data" without an error dialog.
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;
// half-height in pixels a full-scale (|value|==1) sample reaches, minus a
// 2px inset so the waveform never touches the cell border.
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;
// Map bin i -> a column x within the cell's inner width. The envelope was
// computed at `width` bins == the cell's drawable columns, so bin i maps
// to column i; guard anyway if they differ (e.g. cached at another width).
const int innerW = rect.width - 4; // 2px inset each side
for (int i = 0; i < nbins; ++i) {
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
// min<=max always (peaks invariant). Draw a vertical line from the
// min sample to the max sample, clamped to the band.
int yMax = midY - static_cast<int>(bins[i].max * halfSpan); // max -> up
int yMin = midY - static_cast<int>(bins[i].min * halfSpan); // min -> down
if (yMax < bandTop) yMax = bandTop;
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false);
}
}
}
// Draws the empty-state message centered in the client area.
void drawEmptyState(HWND hwnd, LICE_IBitmap* bmp, int w, int h) {
(void)hwnd;
LICE_Clear(bmp, kColBackground);
HDC dc = bmp->getDC();
if (!dc) return;
const char* msg = "No samples in this project's bank yet. Capture one to see it here.";
RECT rc{0, 0, w, h};
SetTextColor(dc, RGB(200, 200, 205));
SetBkMode(dc, TRANSPARENT);
DrawText(dc, msg, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_WORDBREAK);
}
// The cell rects for the panel's CURRENT client width and bank size. Both paint
// and mouse hit-testing call this so they share identical geometry (no drift
// between what is drawn and what a click resolves to). Returns empty when the
// window is gone or the bank is empty.
std::vector<CellRect> panelRects() {
if (!g_panel.hwnd) return {};
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
if (!bank || bank->empty()) return {};
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left;
if (w <= 0) return {};
return computeCellRects(static_cast<int>(bank->size()), w, kGrid);
}
// The full paint: build/refresh the LICE backing bitmap at client size, draw the
// grid (or empty state), then blit to the window HDC.
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;
// A per-paint sysbitmap. Cheap to construct; sized to the client. (Wave A
// keeps it local; if repaint cost ever matters, cache it across paints.)
LICE_SysBitmap bmp(w, h);
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
if (!bank || bank->empty()) {
drawEmptyState(hwnd, &bmp, w, h);
} else {
LICE_Clear(&bmp, kColBackground);
const std::string projectDir = currentProjectDir();
const std::vector<Sample>& samples = bank->all();
const std::vector<CellRect> rects =
computeCellRects(static_cast<int>(samples.size()), w, kGrid);
// Draw each cell's thumbnail. Inner drawable width == cell width - inset;
// compute the envelope at the cell's inner column count so bins map 1:1.
const int binWidth = kGrid.cellWidth - 4;
for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& rect = rects[i];
// Skip cells entirely below the viewport (Wave A has no scroll; this
// just avoids computing thumbnails that cannot be seen).
if (rect.y >= h) continue;
const int idx = static_cast<int>(i);
const bool selected = g_panel.selection.contains(idx);
const bool focused = g_panel.selection.focus == idx;
const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir);
drawThumbnail(&bmp, rect, env, selected, focused);
}
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
// --- Bank-change detection ----------------------------------------------------
// A cheap fingerprint of the bank: count + each sample's id and relative path.
// Ids are unique and stable; including relative paths catches an in-place file
// swap. Cheaper than hashing PCM, sufficient to know "the grid must redraw".
std::string bankFingerprint(const BankIndex& bank) {
std::string fp = std::to_string(bank.size());
for (const Sample& s : bank.all()) {
fp += '\x1f';
fp += s.id;
fp += '\x1f';
fp += s.relativePath;
}
return fp;
}
// Recomputes the fingerprint; on change, bumps the generation and clears the
// cache (bounding memory and invalidating every stale-generation entry). Returns
// true if the bank changed since last check.
bool refreshFingerprint() {
if (!g_panel.session) return false;
std::string fp = bankFingerprint(g_panel.session->bank());
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 bank order; a bank change (capture /
// project load) can invalidate those indices, so clear it and stop any
// audition of a sample that may no longer exist at the same index.
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
g_panel.selection = Selection{};
stopAudition();
}
g_panel.selItemCount = static_cast<int>(g_panel.session->bank().size());
return true;
}
// --- Audition preview ---------------------------------------------------------
//
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
// through REAPER's preview bus and touches nothing in the project.
//
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
// signature/struct, not semantics — DAW-verify):
// 1. REAPER's audio thread reads the preview_register_t by POINTER while the
// preview is active (the struct's own comment mandates a cs/mutex we init),
// so the register must outlive playback — we hold it in g_panel (static),
// never on the stack.
// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it
// returns, making it safe to PCM_Source_Destroy the source immediately after.
// This is the conventional contract (SWS' preview helpers rely on it) but is
// NOT documented in the header — flagged. If a rare race surfaced, the fix is
// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
// set mono (&1024). volume 1.0, loop false, curpos 0.
// Initializes the preview register's cs/mutex ONCE for the panel's lifetime. The
// preview struct guards its fields with a platform lock the caller must set up
// (reaper_plugin.h). Idempotent.
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;
}
// Stops any active preview and frees the owned PCM_source. Safe to call when
// nothing is playing (no-op). Every stop path funnels through here so the source
// is freed exactly once and never dangles.
void stopAudition() {
if (g_panel.previewActive) {
StopPreview(&g_panel.preview);
g_panel.previewActive = false;
}
// Free the source AFTER StopPreview has detached it (assumption #2). Clear the
// register's src so a stale pointer can never be handed back to PlayPreview.
if (g_panel.previewSrc) {
PCM_Source_Destroy(g_panel.previewSrc);
g_panel.previewSrc = nullptr;
}
g_panel.preview.src = nullptr;
}
// Destroys the preview register's cs/mutex on panel teardown, after stopAudition.
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 the sample at bank index `idx`: stops any prior preview, loads the
// sample's file as a PCM_source, and starts stock preview playback. Re-audition
// (calling with a new idx while one plays) stops the previous first. On any
// failure (bad index, unsaved project, unreadable file, PlayPreview refusal) it
// leaves nothing playing and no source leaked.
void startAudition(int idx) {
// Always stop+free the previous first — re-audition semantics, and it clears
// previewSrc so the load below starts clean.
stopAudition();
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
if (!bank) return;
const std::vector<Sample>& samples = bank->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; // unsaved project / unresolvable — nothing to play
PCM_source* src = PCM_Source_CreateFromFile(abs.c_str());
if (!src) return; // unreadable file — no preview, no leak
// Fill the register. cs/mutex already initialized (initPreview at panel open).
g_panel.preview.src = src;
g_panel.preview.m_out_chan = 0; // first hardware output pair (assumption #3)
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; // we now own it until stopAudition frees it
g_panel.previewActive = true;
} else {
// PlayPreview refused — free the source we created rather than leak it.
PCM_Source_Destroy(src);
g_panel.preview.src = nullptr;
}
}
// --- Selection + input --------------------------------------------------------
// True while VK_CONTROL / VK_SHIFT is physically down. SWELL does NOT set MK_* bits
// in a mouse message's wParam (swell-types.h), so modifier state is read live via
// GetAsyncKeyState — the portable path (Win/mac/GDK all support these two VKs).
bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; }
bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; }
// The current bank item count (0 when no session/bank).
int bankItemCount() {
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
return bank ? static_cast<int>(bank->size()) : 0;
}
// Requests a repaint of the whole client area (selection/focus chrome changed).
void invalidatePanel() {
if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}
// Handles a left-button click at client (x, y): hit-test to a cell, update the
// selection through the pure model with the live modifier state, repaint. A click
// on empty space (gap/margin/below grid) clears the selection AND stops audition
// (deselect stop path). READ-ONLY: never mutates the bank/project.
void handleClick(int x, int y) {
const std::vector<CellRect> rects = panelRects();
const int hit = hitTestCell(x, y, rects);
const int count = bankItemCount();
if (hit < 0) {
// Click on empty space clears the selection and stops any audition.
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
g_panel.selection = Selection{};
stopAudition();
invalidatePanel();
}
return;
}
g_panel.selection =
applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count);
g_panel.selItemCount = count;
invalidatePanel();
}
// The column count for the panel's CURRENT client width (nav needs the same wrap
// the layout uses). >= 1.
int columnsNow() {
if (!g_panel.hwnd) return 1;
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
return columnsForWidth(cr.right - cr.left, kGrid);
}
// True iff `hwnd` is our panel window or a descendant of it (the accelerator hook
// only claims keys when focus is inside the panel). Walks the parent chain.
bool isOurWindow(HWND hwnd) {
for (HWND w = hwnd; w; w = GetParent(w))
if (w == g_panel.hwnd) return true;
return false;
}
// Handles a key-down (virtual key `vk`) while the panel is focused. Returns true if
// the key was consumed (arrow nav / Enter/Space audition / Esc stop), false to let
// REAPER handle it. Arrow keys mutate the selection through the pure nav model and
// repaint; Shift extends. READ-ONLY: never mutates the bank/project.
bool handleKey(int vk) {
const int count = bankItemCount();
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, columnsNow(), count, shiftDown());
g_panel.selItemCount = count;
invalidatePanel();
return true;
}
case VK_RETURN:
case VK_SPACE:
// Audition the focused cell. Enter/Space with no focus does nothing
// (nothing to play). Re-audition stops the previous inside startAudition.
if (g_panel.selection.focus >= 0)
startAudition(g_panel.selection.focus);
return true;
case VK_ESCAPE:
// Stop audition (does not clear the selection — Esc is "stop", not
// "deselect"). No-op when nothing is playing; still consume so REAPER
// does not treat Esc as a global stop while the panel is focused.
stopAudition();
return true;
default:
return false;
}
}
// The keyboard accelerator hook (registered with "accelerator"). REAPER calls this
// for every keystroke; we claim arrow/Enter/Space/Esc ONLY when focus is inside the
// panel, eating them so REAPER does not steal arrows for the arrange. Returns 1 to
// eat, 0 to pass on (not our window / not our key).
int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) {
if (!msg || msg->message != WM_KEYDOWN) return 0; // key-down only
if (!g_panel.open || !g_panel.hwnd) return 0;
if (!isOurWindow(GetFocus())) return 0; // focus not in the panel
return handleKey(static_cast<int>(msg->wParam)) ? 1 : 0;
}
accelerator_register_t g_accel{translateAccel, true, nullptr};
bool g_accelRegistered = false;
// Registers the keyboard hook once (on first panel open). isLocal must be true
// (reaper_plugin.h). Safe to call repeatedly.
void registerAccel() {
if (g_accelRegistered || !g_rec) return;
g_rec->Register("accelerator", &g_accel);
g_accelRegistered = true;
}
// Mirror-unregisters the keyboard hook on teardown.
void unregisterAccel() {
if (!g_accelRegistered || !g_rec) return;
g_rec->Register("-accelerator", &g_accel);
g_accelRegistered = false;
}
// --- 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: {
// Take keyboard focus so the accelerator hook routes arrows/audition
// keys to us, then resolve the click. Coordinates are client-relative
// signed shorts in lParam (SWELL sets these even though it omits the
// MK_* modifier bits in wParam — hence GetAsyncKeyState for modifiers).
SetFocus(hwnd);
const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam);
handleClick(x, y);
return 0;
}
case WM_DESTROY:
// REAPER closed the dock (user X'd it). Stop any audition (window-close
// stop path — no preview may outlive the window) and reflect closed
// state so the toggle re-opens rather than reusing a dead HWND.
stopAudition();
g_panel.selection = Selection{};
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;
}
// Create the dialog as a child (WS_CHILD in the template); REAPER's docker
// reparents it. lParam is unused (state lives in g_panel).
// Set up the preview register's lock ONCE before the window can audition.
initPreview();
g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL),
GetMainHwnd(), dlgProc, 0);
if (!g_panel.hwnd) return;
// Dock it. identstr is a stable per-window key REAPER uses to remember the
// dock position/state across sessions; FOREVER-STABLE like the action ids.
// allowShow=true asks REAPER to show the dock if hidden.
DockWindowAddEx(g_panel.hwnd, "ReaSampler Bank", "reasampler_bank_panel", true);
DockWindowActivate(g_panel.hwnd);
g_panel.open = true;
// Start receiving arrow/audition keys while the panel is open.
registerAccel();
// Prime the fingerprint so the first timer tick doesn't count the initial
// bank as a "change" (it's already drawn on open).
refreshFingerprint();
}
void closePanel() {
// Stop audition before the window goes away (window-close stop path). WM_DESTROY
// also stops, but stop here too so a DockWindowRemove that suppresses WM_DESTROY
// still tears the preview down (idempotent: stopAudition no-ops if not playing).
stopAudition();
g_panel.selection = Selection{};
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;
}
void bankPanelRefresh() {
if (!g_panel.open || !g_panel.hwnd) return;
// Repaint only when the bank actually changed (generation bump). Cheap tick
// otherwise — just a fingerprint string compare.
if (refreshFingerprint())
InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}
void bankPanelShutdown() {
closePanel(); // stops audition + destroys the window
deinitPreview(); // destroy the preview lock (after the last stop)
g_panel.cache.clear();
g_panel.session = nullptr;
}
} // namespace reasampler