a67a2f9479
Pool grid on top, LICE-drawn named-banks tab strip below with overflow-scroll (new pure tab_strip seam, unit-tested). Full-height toggles, unmistakable active-bank readout distinct from the shown tab, tab context menu (activate/rename/delete/evacuate/create) with rich confirm-on-non-empty-delete, and move/copy via menu + drag with drop-highlighting. Fold-in: deserialize auto-disambiguates duplicate folded bank names instead of rejecting the book.
1703 lines
66 KiB
C++
1703 lines
66 KiB
C++
// 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() {
|
||
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) {
|
||
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) {
|
||
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; nudge focus to pool if
|
||
// no named banks remain so the selection has a valid home.
|
||
invalidatePanel();
|
||
}
|
||
|
||
void doEvacuateBank(const std::string& bankId) {
|
||
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()->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 (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 pos past the
|
||
// end appending. -1 as an unsigned position appends on Win32; SWELL clamps a large
|
||
// pos to the end.
|
||
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) {
|
||
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_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
|