S10: capture-first ReaSampler 9000 editor — browser, single-capture setup, zones toggle

Reverse S4 auto-select (empty=silence+empty state), add peak-thumbnail capture
browser + bank filter + keyboard-strip drag machine, v3 component state (selection
+ zones), and the S-NAME-1 filename/display rename (UID locked). MSVC min/max
macro collisions fixed post-implementation; 26/26 tests green.
This commit is contained in:
2026-07-26 20:36:36 -04:00
parent 991c190bb8
commit 7151e19432
16 changed files with 1906 additions and 325 deletions
+577 -183
View File
@@ -1,22 +1,30 @@
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge.
// Windows-only (D5); the whole file is guarded so a non-Windows build (not a target,
// but keeps the TU honest) degrades to the CPluginView defaults.
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the
// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is
// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults.
#include "reasampler_editor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <string>
#include <vector>
#include "editor_geometry.h"
#include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "peaks.h" // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "reasampler_vst.h" // kPluginName (the editor title band)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
// LICE — the same drawing stack bank_panel uses. On Windows LICE routes through native
// GDI; no SWELL needed for the child window itself.
#include "wdltypes.h"
#include "lice/lice.h"
#endif
@@ -27,91 +35,194 @@ namespace reasampler::vst {
namespace {
#ifdef _WIN32
constexpr const wchar_t* kChildClassName = L"ReaSamplerVstEditor";
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads
// as the same tool.
// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry
// is in capture_browser / keyboard_strip). The title band names the plugin + a live
// readout; the toggle band carries the Browser/Zones switch; the setup band (single-
// capture face) hosts the keyboard strip + level readout under the browser.
constexpr int kTitleHeight = 24;
constexpr int kToggleHeight = 22;
constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels)
constexpr int kStripBandHeight = 40;
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as
// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.)
const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255);
const LICE_pixel kColTitleBg = LICE_RGBA(20, 20, 22, 255);
const LICE_pixel kColBtnBg = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColBtnHitBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColBtnBorder = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColCardBg = LICE_RGBA(44, 44, 48, 255);
const LICE_pixel kColCardSelBg = LICE_RGBA(48, 72, 64, 255);
const LICE_pixel kColCardBorder = LICE_RGBA(70, 70, 76, 255);
const LICE_pixel kColCardSelBorder = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColTabBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColTabActiveBg = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
const COLORREF kRgbDim = RGB(140, 150, 146);
const LICE_pixel kColZoneSelBg = LICE_RGBA(48, 72, 64, 255);
const LICE_pixel kColCtrlBg = LICE_RGBA(60, 60, 66, 255);
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
// LICE has no built-in font handle here; use GDI text into the bitmap DC, matching
// bank_panel's drawCenteredText approach (SetTextColor + DrawText on getDC()).
void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col,
UINT fmt = DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX) {
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, col);
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
DrawTextA(dc, s, -1, &gr, fmt | DT_END_ELLIPSIS);
}
void drawTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
HDC dc = bmp->getDC();
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, col);
RECT gr{r.left, r.top, r.right, r.bottom};
DrawTextA(dc, s, -1, &gr, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
drawText(bmp, r, s, col, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
// Clamp a MIDI note to [0, 127].
int clampNote(int n) { return n < 0 ? 0 : (n > 127 ? 127 : n); }
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
// common DAW convention REAPER uses).
std::string noteLabel(int note) {
static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"};
if (note < 0) note = 0;
if (note > 127) note = 127;
const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4
return std::string(kNames[note % 12]) + std::to_string(octave);
}
// A short display name for a bank sample id, from the snapshotted list (id if unnamed,
// "?" if the id no longer resolves — e.g. a stale zone).
// Draw a mono peak envelope centered vertically in `r` (mirror of bank_panel::drawThumbnail,
// single channel). Each bin is a vertical line from its min to its max about the midline.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
if (r.width() <= 0 || r.height() <= 0 || env.empty() || env[0].empty()) return;
const ChannelEnvelope& ch = env[0];
const int mid = r.top + r.height() / 2;
const int halfH = r.height() / 2;
const int bins = static_cast<int>(ch.size());
for (int x = 0; x < r.width() && x < bins; ++x) {
const MinMax& mm = ch[static_cast<std::size_t>(x)];
const int yTop = mid - static_cast<int>(mm.max * halfH);
const int yBot = mid - static_cast<int>(mm.min * halfH);
LICE_Line(bmp, r.left + x, yTop, r.left + x, yBot, kColThumb, 1.0f, 0, false);
}
}
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
// resolves — e.g. a zone naming a deleted sample).
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
for (const SampleChoice& c : samples) {
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
}
return "?"; // stale: id not in the live bank
return "?";
}
// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
// per horizontal pixel.
int thumbBins(const BrowserLayout& layout) {
return (std::max)(1, cardThumbnailRect(layout, 0).width());
}
#endif
} // namespace
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size; the host may resize (canResize() == true).
ViewRect r(0, 0, 420, 260);
// Default view size — sized to show a couple of card rows + the setup strip.
ViewRect r(0, 0, 560, 400);
setRect(r);
}
void ReaSamplerEditor::refreshSampleList() {
void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
if (!processor_) {
samples_.clear();
banks_.clear();
visible_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
return;
}
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
map_ = processor_->performanceMap();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// Drop a filter that names a bank no longer present.
if (!activeFilterBankId_.empty()) {
bool found = false;
for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true;
if (!found) activeFilterBankId_.clear();
}
rebuildVisible();
}
void ReaSamplerEditor::commitMapAndReload() {
// UI thread only. Publish the edited map to the processor, then rebuild the instrument
// off the audio thread (reloadFromBank bakes the zones into the live Keymap).
void ReaSamplerEditor::rebuildVisible() {
visible_.clear();
for (const SampleChoice& s : samples_) {
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
visible_.push_back(s);
}
}
void ReaSamplerEditor::commitAndReload() {
// UI thread only. Publish the edited selection + zones to the processor, then rebuild
// the instrument off the audio thread (reloadFromBank bakes them into the live Keymap).
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_);
processor_->reloadFromBank();
#ifdef _WIN32
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
invalidate();
#endif
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
const std::string key = sampleId + "|" + std::to_string(binCount);
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
// parse — the mirror of the processor's decodeRelative. Every failure path caches an
// empty envelope so a broken/missing file is not re-decoded on every paint.
std::string relativePath;
Envelope env;
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath;
}
if (!relativePath.empty()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
std::vector<std::uint8_t> bytes;
std::ifstream f(abs, std::ios::binary | std::ios::ate);
if (f) {
const std::streamoff size = f.tellg();
if (size > 0) {
f.seekg(0, std::ios::beg);
bytes.resize(static_cast<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
}
}
const WavLayout layout = parseWavLayout(bytes);
if (layout.valid) {
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
std::vector<AudioSample> mono =
downmixToMono(interleaved, layout.channelCount);
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
}
}
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
}
ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
// Defensive teardown: the host normally calls removed() (which destroys the child)
// before releasing us, but if we're destroyed while still attached, don't leak the
// window — mirror the create in attachedToParent().
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
@@ -132,17 +243,18 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() {
#ifdef _WIN32
void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
// systemWindow is the parent HWND the host created (CPluginView::attached set it
// from the void* parent when the type is HWND).
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst = reinterpret_cast<HINSTANCE>(
GetWindowLongPtr(parent, GWLP_HINSTANCE));
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
if (!hInst) hInst = GetModuleHandle(nullptr);
// Register the child window class once per module.
static bool classRegistered = false;
if (!classRegistered) {
WNDCLASSW wc{};
@@ -155,17 +267,13 @@ void ReaSamplerEditor::attachedToParent() {
classRegistered = true;
}
// Snapshot the live bank so the first paint shows the sample list.
refreshSampleList();
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(
0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(),
r.getHeight(), parent, nullptr, hInst, nullptr);
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
// Stash `this` so wndProc can route messages back to the instance.
SetWindowLongPtr(childHwnd_, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(this));
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
}
}
@@ -177,15 +285,37 @@ void ReaSamplerEditor::removedFromParent() {
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
// Let CPluginView latch the new rect, then resize the child window to match so the
// LICE surface fills the host-provided seat.
tresult res = CPluginView::onSize(newSize);
if (childHwnd_ && newSize) {
MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE);
thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them
}
return res;
}
// The client bands: title (top), toggle (below title), then the mode content. In the
// browser view the content is the browser grid on top of the single-capture setup band
// (when a capture is picked); in the zones view the content is the zones strip + list.
namespace {
struct EditorBands {
Rect title;
Rect toggleBrowser; // left half of the toggle band
Rect toggleZones; // right half
Rect content; // below the toggle band: the mode's own area
};
EditorBands computeBands(int w, int h) {
EditorBands b;
const int titleH = (std::min)(kTitleHeight, h);
b.title = Rect{0, 0, w, titleH};
const int toggleTop = titleH;
const int toggleBot = (std::min)(h, toggleTop + kToggleHeight);
b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot};
b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot};
b.content = Rect{0, toggleBot, w, h};
return b;
}
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
@@ -193,173 +323,425 @@ void ReaSamplerEditor::paint(HDC hdc) {
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
// Same double-buffered LICE pattern as bank_panel::paintPanel: draw into a
// LICE_SysBitmap, then BitBlt to the window DC.
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, kColBackground);
const KeymapEditorLayout layout = layoutKeymapEditor(w, h);
const EditorLayout& base = layout.base;
const EditorBands bands = computeBands(w, h);
// Title band: the plugin name + a live-state / mode readout.
LICE_FillRect(&bmp, base.titleBar.left, base.titleBar.top, base.titleBar.width(),
base.titleBar.height(), kColTitleBg, 1.0f, 0);
std::string title = "ReaSampler Instrument";
// Title band: product name + live readout.
LICE_FillRect(&bmp, bands.title.left, bands.title.top, bands.title.width(),
bands.title.height(), kColTitleBg, 1.0f, 0);
std::string title = kPluginName;
if (processor_ && processor_->bridge().isConnected()) {
if (samples_.empty()) {
title += " [bank: empty]";
} else if (map_.zones.empty()) {
title += " [Tier 0: pick a sample - Add Zone for a keymap]";
} else {
title += " [keymap: " + std::to_string(map_.zones.size()) + " zone(s)]";
}
if (samples_.empty()) title += " [bank empty]";
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
} else {
title += " [host: no bridge]";
}
Rect titleText{base.titleBar.left + 8, base.titleBar.top, base.titleBar.right - 8,
base.titleBar.bottom};
Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8,
bands.title.bottom};
drawText(&bmp, titleText, title.c_str(), kRgbText);
// LEFT column: the bank-sample list. The selected id (fallback + "sample to add" for
// a new zone) is highlighted. Clip rows past the visible list.
for (int i = 0; i < static_cast<int>(samples_.size()); ++i) {
const Rect row = keymapSampleRowRect(layout, i);
if (row.top >= layout.sampleList.bottom) break;
const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_;
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0);
if (sel) {
LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1,
kColBtnBorder, 1.0f, 0);
}
Rect textR{row.left + 8, row.top, row.right - 8, row.bottom};
const std::string& name = samples_[i].displayName;
drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(),
kRgbText);
}
// Toggle band: Browser | Zones.
const bool inZones = (view_ == View::kZones);
LICE_FillRect(&bmp, bands.toggleBrowser.left, bands.toggleBrowser.top,
bands.toggleBrowser.width(), bands.toggleBrowser.height(),
inZones ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(&bmp, bands.toggleZones.left, bands.toggleZones.top,
bands.toggleZones.width(), bands.toggleZones.height(),
inZones ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(&bmp, bands.toggleBrowser, "Browser", kRgbText);
drawTextCentered(&bmp, bands.toggleZones, "Zones", kRgbText);
// RIGHT column: the zone panel. "Add Zone" button on top, then one row per zone.
LICE_FillRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
layout.addZoneButton.width(), layout.addZoneButton.height(), kColBtnBg,
1.0f, 0);
LICE_DrawRect(&bmp, layout.addZoneButton.left, layout.addZoneButton.top,
layout.addZoneButton.width() - 1, layout.addZoneButton.height() - 1,
kColBtnBorder, 1.0f, 0);
drawTextCentered(&bmp, layout.addZoneButton, "+ Add Zone", kRgbText);
// The seven per-row controls, laid out left-to-right pinned to the right edge.
const char* kCtrlGlyphs[7] = {"-", "+", "-", "+", "-", "+", "x"};
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const Rect row = zoneRowRect(layout, i);
if (row.top >= layout.zoneRowArea.bottom) break; // past the visible panel
const bool sel = (i == selectedZone_);
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
sel ? kColZoneSelBg : kColBtnBg, 1.0f, 0);
const PerformanceZone& z = map_.zones[i];
std::string label = sampleLabel(samples_, z.sampleId);
label += " " + std::to_string(z.lowNote) + "-" + std::to_string(z.highNote);
label += " r" + (z.rootOverride ? std::to_string(*z.rootOverride) + "*"
: std::string("(bank)"));
// Label area stops where the control block begins (7 * ctrl width from the right).
const int ctrlBlockLeft = row.right - 7 * kZoneCtrlWidth;
Rect textR{row.left + 6, row.top, ctrlBlockLeft - 4, row.bottom};
drawText(&bmp, textR, label.c_str(), kRgbText);
// Draw the 7 mini-buttons.
for (int s = 0; s < 7; ++s) {
const int bx = ctrlBlockLeft + s * kZoneCtrlWidth;
Rect cell{bx, row.top + 2, bx + kZoneCtrlWidth - 1, row.bottom - 2};
LICE_FillRect(&bmp, cell.left, cell.top, cell.width(), cell.height(),
kColCtrlBg, 1.0f, 0);
drawTextCentered(&bmp, cell, kCtrlGlyphs[s], kRgbText);
}
if (view_ == View::kZones) {
paintZones(&bmp, w, h);
} else {
paintBrowser(&bmp, w, h);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
void ReaSamplerEditor::onClick(int x, int y) {
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
const char* msg = samples_.empty()
? "No captures in this project yet — capture audio into the bank to play it here."
: "No captures in this bank filter. Choose another bank tab above.";
drawTextCentered(bmp, area, msg, kRgbDim);
}
void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const EditorBands bands = computeBands(w, h);
// When a capture is picked, the setup band takes the bottom; the browser gets the rest.
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int ox = browserArea.left;
const int oy = browserArea.top;
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
const int tabCount = static_cast<int>(banks_.size()) + 1;
for (int i = 0; i < tabCount; ++i) {
Rect t = filterTabRect(bl, tabCount, i);
t = Rect{t.left + ox, t.top + oy, t.right + ox, t.bottom + oy};
const std::string label = (i == 0) ? "All" : banks_[static_cast<std::size_t>(i - 1)].displayName;
const bool active = (i == 0) ? activeFilterBankId_.empty()
: (banks_[static_cast<std::size_t>(i - 1)].id == activeFilterBankId_);
LICE_FillRect(bmp, t.left, t.top, t.width(), t.height(),
active ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, t, label.c_str(), kRgbText);
}
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
const int bins = thumbBins(bl);
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
Rect content = cardContentRect(bl, i);
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
Rect thumb = cardThumbnailRect(bl, i);
Rect labelR = cardLabelRect(bl, i);
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
const bool sel = (s.id == selectedId_);
LICE_FillRect(bmp, content.left, content.top, content.width(), content.height(),
sel ? kColCardSelBg : kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
sel ? kColCardSelBorder : kColCardBorder, 1.0f, 0);
drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins));
// Name + root/key badge under the thumbnail.
std::string caption = s.displayName.empty() ? s.id : s.displayName;
Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom};
drawText(bmp, nameR, caption.c_str(), kRgbText);
std::string badge;
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
else if (s.key) badge = *s.key;
else badge = "root —";
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
}
if (havePick) {
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
} else if (visible_.empty()) {
paintEmptyState(bmp, browserArea);
}
}
void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
// The guided single-capture setup: the picked capture's name + root/level, and a
// keyboard strip with its root marker (drag to set root).
LICE_FillRect(bmp, area.left, area.top, area.width(), area.height(),
kColTitleBg, 1.0f, 0);
// Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
int root = 60;
for (const SampleChoice& s : visible_) {
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
}
// If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
}
const int pad = 8;
Rect headerR{area.left + pad, area.top + 4, area.right - pad, area.top + 22};
std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root);
drawText(bmp, headerR, header.c_str(), kRgbText);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim);
// Keyboard strip with the root marker.
const int stripTop = area.bottom - kStripBandHeight;
Rect stripArea{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
// Faint per-octave key ticks for orientation.
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
Rect marker = rootMarkerRect(sl, root);
LICE_FillRect(bmp, marker.left + sx, sy, (std::max)(2, marker.width()), stripArea.height(),
kColRootMarker, 1.0f, 0);
}
void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
const EditorBands bands = computeBands(w, h);
const int pad = 8;
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
LICE_FillRect(bmp, addR.left, addR.top, addR.width(), addR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, addR.left, addR.top, addR.width() - 1, addR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, addR, "+ Add Zone", kRgbText);
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0) {
LICE_FillRect(bmp, delR.left, delR.top, delR.width(), delR.height(), kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, delR.left, delR.top, delR.width() - 1, delR.height() - 1,
kColCardSelBorder, 1.0f, 0);
drawTextCentered(bmp, delR, "Delete", kRgbText);
}
// The zones strip.
const int stripTop = addR.bottom + 12;
Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad,
stripTop + kStripBandHeight};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
LICE_FillRect(bmp, stripArea.left, stripArea.top, stripArea.width(), stripArea.height(),
kColStripBg, 1.0f, 0);
for (int n = 0; n <= 127; n += 12) {
Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + stripArea.height(), kColStripKey,
1.0f, 0, false);
}
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
const bool sel = (i == selectedZone_);
LICE_FillRect(bmp, bar.left + sx, sy, (std::max)(2, bar.width()), stripArea.height(),
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
}
// A one-line legend of the selected zone below the strip.
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
" " + noteLabel(z.highNote) + " root " +
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
drawText(bmp, infoR, info.c_str(), kRgbText);
} else if (map_.zones.empty()) {
drawText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
}
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const KeymapEditorLayout layout =
layoutKeymapEditor(cr.right - cr.left, cr.bottom - cr.top);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
const EditorBands bands = computeBands(w, h);
// 1. Left list: pick the Tier-0 fallback / the sample a new zone will use.
const int row =
keymapSampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
if (row >= 0) {
processor_->setSelectedSampleId(samples_[row].id);
selectedId_ = samples_[row].id;
// A fallback change only affects playback when the map is empty; reload so the
// Tier-0 case updates immediately.
processor_->reloadFromBank();
InvalidateRect(childHwnd_, nullptr, FALSE);
// Toggle band: switch views.
if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; }
if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; }
if (view_ == View::kBrowser) {
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int bx = x - browserArea.left;
const int by = y - browserArea.top;
// Filter tabs.
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) {
activeFilterBankId_ = (tab == 0) ? std::string()
: banks_[static_cast<std::size_t>(tab - 1)].id;
rebuildVisible();
invalidate();
return;
}
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
if (card >= 0) {
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
return;
}
// The setup strip: grab the root marker (drag to set the picked capture's root).
if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const int stripTop = area.bottom - kStripBandHeight;
const Rect stripArea{area.left + 8, stripTop, area.right - 8, area.bottom - 4};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top);
if (note >= 0) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
// A click sets the root immediately (drag then refines); the override lives on
// a one-zone map entry for the picked capture (D-B, never written to the bank).
onMouseMove(x, y); // apply the click position as the first delta==0 set
return;
}
}
return;
}
// 2. "Add Zone": append a full-keyboard zone for the currently-selected sample (root
// from the bank intrinsic — no override until the user nudges it).
if (addZoneHitTest(layout, x, y)) {
if (selectedId_.empty()) return; // nothing selected to add
// Zones view.
const int pad = 8;
Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96,
bands.content.top + 4 + 20};
if (contains(addR, x, y)) {
// Append a full-keyboard zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add.
std::string seed = !selectedId_.empty() ? selectedId_
: (!visible_.empty() ? visible_.front().id : std::string());
if (seed.empty()) return;
PerformanceZone z;
z.sampleId = selectedId_;
z.sampleId = seed;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitMapAndReload();
commitAndReload();
return;
}
// 3. Zone rows: select a zone, nudge its range/root, or delete it.
const ZoneHit hit =
zoneHitTest(layout, static_cast<int>(map_.zones.size()), x, y);
if (hit.zoneIndex < 0) return;
selectedZone_ = hit.zoneIndex;
if (hit.field == ZoneField::kZoneNone) {
InvalidateRect(childHwnd_, nullptr, FALSE); // select only, no reload
return;
}
if (hit.field == ZoneField::kDelete) {
map_.zones.erase(map_.zones.begin() + hit.zoneIndex);
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0 && contains(delR, x, y)) {
map_.zones.erase(map_.zones.begin() + selectedZone_);
selectedZone_ = -1;
commitMapAndReload();
commitAndReload();
return;
}
PerformanceZone& z = map_.zones[hit.zoneIndex];
switch (hit.field) {
case ZoneField::kLowDown: z.lowNote = clampNote(z.lowNote - 1); break;
case ZoneField::kLowUp: z.lowNote = clampNote(z.lowNote + 1); break;
case ZoneField::kHighDown: z.highNote = clampNote(z.highNote - 1); break;
case ZoneField::kHighUp: z.highNote = clampNote(z.highNote + 1); break;
case ZoneField::kRootDown: {
const int base = z.rootOverride ? *z.rootOverride : 60;
z.rootOverride = clampNote(base - 1); // first nudge establishes the override
break;
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
// selected zone's root.
const int stripTop = addR.bottom + 12;
const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad,
stripTop + kStripBandHeight};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int lx = x - stripArea.left;
const int ly = y - stripArea.top;
std::vector<int> lows, highs;
lows.reserve(map_.zones.size());
highs.reserve(map_.zones.size());
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
highs.empty() ? nullptr : highs.data(),
static_cast<int>(map_.zones.size()), lx, ly);
if (hit.zoneIndex >= 0) {
selectedZone_ = hit.zoneIndex;
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
dragStartX_ = x;
dragStartLow_ = z.lowNote;
dragStartHigh_ = z.highNote;
switch (hit.grab) {
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
default: drag_ = DragKind::kNone; break;
}
case ZoneField::kRootUp: {
const int base = z.rootOverride ? *z.rootOverride : 60;
z.rootOverride = clampNote(base + 1);
break;
invalidate();
return;
}
// A bare key-click inside the strip sets the selected zone's root override.
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
const int note = keyAtPoint(sl, lx, ly);
if (note >= 0) {
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
commitAndReload();
}
default: break; // kZoneNone/kDelete handled above
}
// Keep low <= high after a range nudge (clamp the moved edge against its partner).
if (z.lowNote > z.highNote) {
if (hit.field == ZoneField::kLowUp) z.lowNote = z.highNote;
else if (hit.field == ZoneField::kHighDown) z.highNote = z.lowNote;
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
const EditorBands bands = computeBands(w, h);
const int dx = x - dragStartX_;
if (drag_ == DragKind::kRootMarker) {
// The single-capture root strip lives in the setup band.
const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight);
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const Rect stripArea{area.left + 8, area.bottom - kStripBandHeight, area.right - 8,
area.bottom - 4};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int note = resolveDragNote(sl, dragStartRoot_, dx);
// The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone),
// so setting the single capture's root materializes a full-keyboard zone carrying the
// override. This plays identically to the un-zoned single-capture path (one chromatic
// zone over the whole keyboard) and round-trips through the v3 component state; the
// zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a
// repeated drag edits the same zone rather than stacking duplicates.
bool found = false;
for (PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) { z.rootOverride = note; found = true; break; }
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = note;
map_.zones.push_back(z);
}
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return;
}
commitMapAndReload();
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const int pad = 8;
const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12
const Rect stripArea{bands.content.left + pad, stripTop, bands.content.right - pad,
stripTop + kStripBandHeight};
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (drag_ == DragKind::kZoneLow) {
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
} else if (drag_ == DragKind::kZoneHigh) {
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
} else if (drag_ == DragKind::kZoneBody) {
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
const int span = dragStartHigh_ - dragStartLow_;
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
else { z.lowNote = newLow; z.highNote = newHigh; }
}
invalidate();
}
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
if (drag_ == DragKind::kNone) return;
drag_ = DragKind::kNone;
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
commitAndReload();
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self = reinterpret_cast<ReaSamplerEditor*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA));
auto* self =
reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_PAINT: {
PAINTSTRUCT ps{};
@@ -369,10 +751,22 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
return 0;
}
case WM_LBUTTONDOWN:
if (self) self->onClick(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
if (self) {
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
}
return 0;
case WM_MOUSEMOVE:
if (self) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
return 0;
case WM_LBUTTONUP:
if (self) {
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
ReleaseCapture();
}
return 0;
case WM_ERASEBKGND:
return 1; // we fully repaint in WM_PAINT; skip the flicker-inducing erase
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
default:
return DefWindowProcW(hwnd, msg, wParam, lParam);
}