Files
reasampler/src/vst/reasampler_editor.cpp
T

1927 lines
98 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry
#include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
#include "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3)
#include "note_entry.h" // S12 direct numeric note-entry parse
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
#include "peaks.h" // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept
#include "wdltypes.h"
#include "lice/lice.h"
#endif
using namespace Steinberg;
namespace reasampler::vst {
namespace {
#ifdef _WIN32
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence"
// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many
// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough
// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
// 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 = 176; // the single-capture setup surface (labels + waveform + strip)
constexpr int kStripBandHeight = 40;
constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's
// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class,
// §2.1), not the live/active layer, so they take the categorical accents: start = teal
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
//
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox`
// (component_geometry). This is the single boundary that bridges them so every draw routes
// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette +
// GDI DrawTextA path.
KitBox toKitBox(const Rect& r) {
return KitBox{r.left, r.top, r.width(), r.height()};
}
// Kit text in a palette ROLE (the common case). Left/Right/Center via Align.
void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role,
Align align = Align::Left) {
text(bmp, toKitBox(r), s, font, role, align);
}
void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) {
text(bmp, toKitBox(r), s, font, role, Align::Center);
}
// 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);
}
// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3):
// midline + accent-primary min/max columns with the same dB display compression the dock
// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has
// already filled the surface behind it (bg/panel), matching drawWaveform's contract.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
drawWaveform(bmp, toKitBox(r), env);
}
// 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 "?";
}
// 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 — 840×560 gives comfortable room for the three-band Sample face
// on a 1080p screen (an interim canvas; Wave 2 tunes final band-height numbers).
ViewRect r(0, 0, 840, 560);
setRect(r);
}
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
pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source)
if (!processor_) {
samples_.clear();
banks_.clear();
visible_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
return;
}
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();
channelMode_ = processor_->channelMode();
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::rebuildVisible() {
// S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search
// narrows the survivors by name substring (nameMatchesQuery — empty query is the identity).
visible_.clear();
for (const SampleChoice& s : samples_) {
const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_;
if (!inBank) continue;
const std::string& name = s.displayName.empty() ? s.id : s.displayName;
if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s);
}
// NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel
// height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it
// must not reset the user's scroll here.
}
#ifdef _WIN32
// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared
// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes
// Windows the only build target, but the TU must still compile elsewhere).
void ReaSamplerEditor::onSyncTimer() {
// UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the
// processor (off the audio thread — the poll itself never touches process()). NEVER while a
// drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the
// user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release.
if (!processor_) return;
if (drag_ != DragKind::kNone) return; // defer past the in-flight edit
// An open editor marks THIS instance the focused assignment target (the thundering-herd
// policy — only an editor-open instance applies a pending assign; see the handoff). Pass
// true so this instance consumes the request; instances with no editor open do not poll at
// all (the timer is bound to the child window), so they never contend for the request.
const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true);
// Re-snapshot the editor's own view only when something changed (a reload from a bank
// content change, or an applied assignment). refreshFromBank re-reads the bank blob + the
// processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM
// caches, then repaints — so the browser + setup surface reflect the new bank hands-free.
if (r.reloaded || r.applied) {
refreshFromBank();
invalidate();
}
// S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop.
if (dropHintTicks_ > 0) {
--dropHintTicks_;
invalidate();
}
}
#endif // _WIN32
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
invalidate();
#endif
}
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
// from the live bank blob (the same path selectSample uses); the override lives in map_.
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, selectedId_)) {
if (sel->loop.hasLoop) {
m.hasLoop = true;
m.loopStart = sel->loop.start;
m.loopEnd = sel->loop.end;
}
}
}
}
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId != selectedId_) continue;
if (z.loopOverride) {
m.hasLoop = z.loopOverride->hasLoop;
m.loopStart = z.loopOverride->start;
m.loopEnd = z.loopOverride->end;
}
if (z.startPoint) m.start = *z.startPoint;
break;
}
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
// the markers are still drawn (drag one to CREATE a loop).
if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0;
return m;
}
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
// be non-empty; callers are responsible for that guard.
// Returns the zone index (0-based) so callers can update selectedZone_.
SampleLoop loop;
loop.hasLoop = m.hasLoop;
loop.start = m.loopStart;
loop.end = m.loopEnd;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.loopOverride = loop;
z.startPoint = m.start;
return i;
}
}
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.loopOverride = loop;
z.startPoint = m.start;
map_.zones.push_back(z);
return static_cast<int>(map_.zones.size()) - 1;
}
namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
// engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span
// [0, kEnvTimeMaxSeconds] SECONDS — rate-free, exactly what the zone stores; the keymap build
// resolves seconds->frames at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out)
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// wall-clock second). Build-time residual — one place to retune; not persisted.
constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds)
constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
std::vector<ControlDesc> out;
// Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
// Mode-relevant amplitude sliders.
if (play.playMode == PlayMode::Gate) {
out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
} else { // Trigger
out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
}
// The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
return out;
}
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [](std::int64_t f) {
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0;
case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0;
case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds);
case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds);
case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds);
case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction);
case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames);
case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames);
case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0;
case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds);
case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds);
case ParamControl::kPitchEnvDepth:
// Signed depth centered at 0.5 (0.5 == 0 semitones).
return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis));
default: return 0.0;
}
}
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
int segment) const {
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [](double v) {
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 0.5);
};
switch (static_cast<ParamControl>(id)) {
case ParamControl::kPlayMode:
play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate;
break;
case ParamControl::kPitchEngine:
play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed;
break;
case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break;
case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break;
case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break;
case ParamControl::kTrigLength:
// lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, clamp01(value));
break;
case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break;
case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break;
case ParamControl::kPitchEnvEnable:
play.pitchEnv.enabled = (segment == 1);
break;
case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
default: break;
}
}
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
// intrinsic is NEVER written (read-only bank consumer, D-B).
if (selectedId_.empty()) return;
upsertPickedOverride(m);
commitAndReload();
}
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.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
// vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
std::string relativePath;
std::vector<AudioSample> mono;
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());
mono = downmixToMono(interleaved, layout.channelCount);
}
}
}
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
return ins.first->second;
}
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;
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
// every thumbnail width AND the S11 waveform surface + snap.
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
Envelope env;
if (!mono.empty()) {
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
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
#endif
}
tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
#ifdef _WIN32
if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
#endif
return kResultFalse;
}
tresult PLUGIN_API ReaSamplerEditor::canResize() {
return kResultTrue;
}
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
// Enforce a minimum usable floor: at least 560 wide and 380 tall. The host calls this
// before every resize; clamp the proposed rect in place and return kResultTrue so the
// host applies the (possibly adjusted) rect rather than the raw user drag.
// 560×380 keeps the title band + toggle band + a couple of card rows + the setup strip
// visible; anything smaller would clip essential UI. The default 840×560 is above this
// floor — Wave 2 tunes final numbers once the three-band layout is in.
constexpr int kMinW = 560;
constexpr int kMinH = 380;
if (!rect) return kResultFalse;
if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW;
if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH;
return kResultTrue;
}
#ifdef _WIN32
void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<HWND>(systemWindow);
if (!parent) return;
HINSTANCE hInst =
reinterpret_cast<HINSTANCE>(GetWindowLongPtr(parent, GWLP_HINSTANCE));
if (!hInst) hInst = GetModuleHandle(nullptr);
static bool classRegistered = false;
if (!classRegistered) {
WNDCLASSW wc{};
wc.lpfnWndProc = &ReaSamplerEditor::wndProc;
wc.hInstance = hInst;
wc.lpszClassName = kChildClassName;
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.style = CS_HREDRAW | CS_VREDRAW;
RegisterClassW(&wc);
classRegistered = true;
}
// Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a
// reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on
// editor close: the embed strip in the SAME binary shares the kit's process-global font
// set, so a per-view shutdown could free fonts still in use by the other view. The tiny
// static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note.
kitFontsInit();
refreshFromBank();
const ViewRect& r = getRect();
childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0,
r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr);
if (childHwnd_) {
SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(this));
// S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT
// ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show
// the "drop on the panel" affordance instead of the OS bouncing the drop silently.
DragAcceptFiles(childHwnd_, TRUE);
// Start the S9/S8 change-detection poll (UI thread). Tied to the child window's
// lifetime — created here, killed in removedFromParent — so an instance whose editor
// is closed does NOT poll (the editor-open-only cadence; see the handoff limitation).
SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr);
// Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor
// was closed) or a bank change applies the instant the editor opens, rather than waiting
// up to one timer interval. refreshFromBank above already primed the view; this folds in
// any pending assign/generation so the just-opened editor shows the assigned capture.
onSyncTimer();
}
}
void ReaSamplerEditor::removedFromParent() {
if (childHwnd_) {
KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
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;
}
// The keyboard strip rectangle inside the setup area (single-capture root-drag face).
// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal
// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula.
Rect setupStripArea(const Rect& area) {
constexpr int pad = 8;
const int stripTop = area.bottom - kStripBandHeight;
return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
}
// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the
// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and
// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this
// single formula so the draw and the hit-test never drift.
Rect setupWaveformArea(const Rect& area) {
constexpr int pad = 8;
const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip
const int waveTop = waveBottom - kWaveformHeight;
return Rect{area.left + pad, waveTop, area.right - pad, waveBottom};
}
// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the
// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20)
// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown,
// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift.
Rect zonesStripArea(const EditorBands& bands) {
constexpr int pad = 8;
const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12
return Rect{bands.content.left + pad, stripTop, bands.content.right - pad,
stripTop + kStripBandHeight};
}
// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the
// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw +
// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so
// the legend top tracks the strip bottom without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const EditorBands& bands) {
const int stripBottom = zonesStripArea(bands).bottom;
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8)
return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18};
}
// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal
// segments left-to-right. An out-of-range index yields an empty rect.
Rect noteEntryFieldRect(const Rect& fields, int f) {
if (f < 0 || f > 2 || fields.width() <= 0) return Rect{};
const int segW = fields.width() / 3;
const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW;
return Rect{left, fields.top, right, fields.bottom};
}
// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip +
// the one-line selected-zone legend, running to the content bottom. `bands.content` is the
// Zones mode-content area. Both draw + hit-test use this single formula so they never drift.
Rect zonesControlPanel(const EditorBands& bands) {
constexpr int pad = 8;
const Rect strip = zonesStripArea(bands);
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
return Rect{bands.content.left + pad, panelTop, bands.content.right - pad,
bands.content.bottom - 4};
}
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region).
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
struct ChannelToggleRects { Rect mono; Rect stereo; };
ChannelToggleRects channelToggleRects(const Rect& area) {
constexpr int pad = 8;
const int top = area.top + 4;
const int right = area.right - pad;
const Rect stereo{right - kChanSegW, top, right, top + kChanSegH};
const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
return {mono, stereo};
}
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
// so both read as the same spectrum. `stripArea` is the absolute strip rect.
void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) {
if (stripArea.width() <= 0 || stripArea.height() <= 0) return;
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
// A pastel spectral column per key. Each key's local x from keyRect; fill from this key's
// left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet
// backdrop the root/zone marks sit over.
for (int n = 0; n <= 127; ++n) {
const Rect k = keyRect(sl, n);
const int x0 = k.left + sx;
const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right;
const int cw = (std::max)(1, x1 - x0);
const KitColor hue = spectralColor(static_cast<double>(n) / 127.0);
LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
}
// Faint per-octave key ticks (hairline role) for orientation.
const LICE_pixel tick = toLice(roleColor(Role::LineHairline));
for (int n = 0; n <= 127; n += 12) {
const Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false);
}
}
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC
// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated.
void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) {
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
const Rect marker = rootMarkerRect(sl, root);
const int mw = (std::max)(2, marker.width());
const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel glow = toLice(roleColor(Role::AccentHot));
// Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0);
}
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &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, toLice(roleColor(Role::BgBase)));
const EditorBands bands = computeBands(w, h);
// Title band: product name + live readout. Standard B palette — the beta channel gets NO
// distinct accent (settled 2026-07-27); the channel-derived vstPluginName is the only
// beta-vs-stable signal. bg/panel one step up from the canvas, primary-role title text.
fillSurface(&bmp, toKitBox(bands.title), Role::BgPanel, InteractionState::Rest);
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) {
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{bands.title.left + 8, bands.title.top, bands.title.right - 8,
bands.title.bottom};
kitText(&bmp, titleText, title.c_str(), Font::Title, Role::TextPrimary);
// Toggle band: Browser | Zones — two segmented switches. Active = accent-primary fill
// ("this is live"); hover lightens the inactive segment toward accent/hot.
const bool inZones = (view_ == View::kZones);
const InteractionState browserState =
!inZones ? InteractionState::Active
: (isHovered(HoverKind::kToggleBrowser, -1) ? InteractionState::Hover
: InteractionState::Rest);
const InteractionState zonesState =
inZones ? InteractionState::Active
: (isHovered(HoverKind::kToggleZones, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(&bmp, toKitBox(bands.toggleBrowser), Role::BgCell, browserState);
fillSurface(&bmp, toKitBox(bands.toggleZones), Role::BgCell, zonesState);
// Active segment's label sits on the accent fill — draw it in bg/base for contrast
// (the tight text-on-pastel-fill pair, §4); the inactive label stays text/primary.
kitTextCentered(&bmp, bands.toggleBrowser, "Browser", Font::Label,
!inZones ? Role::BgBase : Role::TextPrimary);
kitTextCentered(&bmp, bands.toggleZones, "Zones", Font::Label,
inZones ? Role::BgBase : Role::TextPrimary);
if (view_ == View::kZones) {
paintZones(&bmp, w, h);
} else {
paintBrowser(&bmp, w, h);
}
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
// LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerH = (std::min)(kTitleHeight + 8, h);
Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH};
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
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.";
// Split the area so the primary line sits centered and the S13 ingest affordance sits just
// below it. The affordance is the SHIPPED ingest gesture (drop onto the docked panel) — kept
// discoverable here regardless of whether a drop ever lands on THIS window.
Rect primary{area.left, area.top, area.right, area.top + area.height() / 2};
Rect hint{area.left, primary.bottom, area.right, area.bottom};
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
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 fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid
// sit below it. The search box spans the browser width.
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
// A focused search box lifts to the focus state (a nudge toward the primary accent + a
// text/primary ring drawn below); hover lightens; else the resting cell surface.
const InteractionState searchState =
searchFocused_ ? InteractionState::Focus
: (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
if (searchFocused_) {
const KitColor ring = roleColor(Role::TextPrimary);
LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1,
searchAbs.height() - 1, toLice(ring), 1.0f, 0);
}
{
std::string sb = searchQuery_.empty()
? std::string("Search captures...")
: ("Search: " + searchQuery_ + (searchFocused_ ? "_" : ""));
Rect sbText{searchAbs.left + 6, searchAbs.top, searchAbs.right - 6, searchAbs.bottom};
kitText(bmp, sbText, sb.c_str(), Font::Label,
searchQuery_.empty() ? Role::TextDim : Role::TextPrimary);
}
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.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;
scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);
// 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_);
const InteractionState state =
active ? InteractionState::Active
: (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(t), Role::BgCell, state);
kitTextCentered(bmp, t, label.c_str(), Font::Label,
active ? Role::BgBase : Role::TextPrimary);
}
// Cards: only the S12 visible window at the current scroll offset (a bank longer than the
// panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the
// offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges.
const int bins = thumbBins(bl);
const int cardCount = static_cast<int>(visible_.size());
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
for (int i = vr.first; i < vr.last; ++i) {
// The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives,
// shifted by the scroll offset (they share the cell's top, so subtract the offset).
Rect content = cardContentRect(bl, i);
Rect thumb = cardThumbnailRect(bl, i);
Rect labelR = cardLabelRect(bl, i);
content = Rect{content.left + ox, content.top + oy - scrollOffset_,
content.right + ox, content.bottom + oy - scrollOffset_};
thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_,
thumb.right + ox, thumb.bottom + oy - scrollOffset_};
labelR = Rect{labelR.left + ox, labelR.top + oy - scrollOffset_,
labelR.right + ox, labelR.bottom + oy - scrollOffset_};
const SampleChoice& s = visible_[static_cast<std::size_t>(i)];
const bool sel = (s.id == selectedId_);
// Card surface: a plain cell (hover lightens), with the SELECTED pick marked by an
// accent-primary border (the "this is live" signal, §2.1) — the same convention the
// dock panel's L7 selection uses (normal cell + accent border, no inversion). The
// waveform thumbnail draws over bg/panel so its accent columns read against the cell.
const InteractionState cardState =
isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest;
fillSurface(bmp, toKitBox(content), Role::BgCell, cardState);
const KitColor cardBorder =
sel ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1,
toLice(cardBorder), 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};
kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary);
std::string badge;
if (s.rootNote) badge = "root " + noteLabel(*s.rootNote);
else if (s.key) badge = *s.key;
else badge = "root -";
kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim);
}
// S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
// module (empty when the content fits — the shell simply draws nothing then). Offset by the
// browser origin like every other card rect.
{
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
if (thumb.height() > 0) {
const bool dragging = (drag_ == DragKind::kScrollThumb);
const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary);
LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
toLice(tc), 0.8f, 0);
}
}
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). A raised bg/panel region.
fillSurface(bmp, toKitBox(area), Role::BgPanel, InteractionState::Rest);
// Effective root: the picked sample's rootNote intrinsic (or middle C when unset).
// Read from samples_ (the full unfiltered list) so a bank-filter that hides the
// picked sample's bank doesn't mask its intrinsic root with the C4 default.
int root = 60;
for (const SampleChoice& s : samples_) {
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;
// The mono/stereo toggle sits at the right of the header row; keep the name text clear of it.
const ChannelToggleRects chan = channelToggleRects(area);
Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22};
std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root);
kitText(bmp, headerR, header.c_str(), Font::Label, Role::TextPrimary);
// S7 mono | stereo output-mode toggle. Active segment = accent-primary fill (the live
// mode); inactive lightens on hover — the same visual grammar as the Browser/Zones toggle.
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
const InteractionState monoState =
!isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
: InteractionState::Rest);
const InteractionState stereoState =
isStereo ? InteractionState::Active
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
kitTextCentered(bmp, chan.mono, "Mono", Font::Label,
!isStereo ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label,
isStereo ? Role::BgBase : Role::TextPrimary);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
kitText(bmp, hintR,
"Drag the waveform markers to set start + loop; drag the keyboard to set root.",
Font::Micro, Role::TextDim);
// --- S11 waveform surface: the picked capture's envelope + draggable markers ----------
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = setupWaveformArea(area);
// The waveform pane is a recessed surface (bg/base, one step DOWN from the setup panel)
// so the accent-primary envelope reads against it.
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
if (frames > 0 && waveArea.width() > 0) {
// Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view
// reused). computeEnvelope over the cached mono frames — no new decode.
const int bins = (std::max)(1, waveArea.width());
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(bins));
drawEnvelope(bmp, waveArea, env); // kit drawWaveform — accent-primary columns
const SetupMarkers m = pickedMarkers(frames);
// Faint loop-region fill between the loop markers (only when a loop is set) — the
// categorical loop-marker hue (tertiary purple) at low alpha.
if (m.hasLoop && m.loopEnd > m.loopStart) {
const int lx = frameToX(waveArea, frames, m.loopStart);
const int rx = frameToX(waveArea, frames, m.loopEnd);
if (rx > lx) {
LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(),
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
}
}
// The three markers: start (teal, secondary) + loop start/end (purple, tertiary) —
// categorical affordance hues (§2.1), 2px vertical lines the full waveform height.
// Loop markers dim when no loop is set (the "no loop" state — draggable to CREATE one).
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
for (int i = 0; i < 3; ++i) {
const int mx = frameToX(waveArea, frames, markerFrames[i]);
const bool loopMarker = (i != 0);
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(),
toLice(roleColor(markerRoles[i])), alpha, 0);
}
} else {
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
}
// Keyboard strip with the root marker — the signature Direction-C PASTEL SPECTRAL surface
// (§4). Each key column is hue-mapped low->high across the accent trio (spectralColor:
// lime -> teal -> purple), so the strip reads as an extension of the accent system. The
// root marker lifts to accent-primary with a STATIC glow (never a pulse — §3.5).
const Rect stripArea = setupStripArea(area);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
drawRootMarker(bmp, stripArea, sl, root);
}
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};
{
const KitButtonBox box{toKitBox(addR)};
const InteractionState state =
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
}
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0) {
const KitButtonBox box{toKitBox(delR)};
const InteractionState state =
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
// read-only here), so it is a normal button, not `warn`.
drawButton(bmp, box, "Delete", state, /*warn=*/false);
}
// The zones strip — the same PASTEL SPECTRAL surface as the setup face (§4), with one
// bar per zone over the spectrum. The SELECTED zone lifts to accent-primary + a static
// glow ("which zone is live"); the rest take the categorical secondary hue at low alpha.
const Rect stripArea = zonesStripArea(bands);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
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 int bw = (std::max)(2, bar.width());
const bool sel = (i == selectedZone_);
if (sel) {
// Static glow halo behind the live zone, then the crisp accent-primary bar.
LICE_FillRect(bmp, bar.left + sx - 2, sy, bw + 4, stripArea.height(),
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
}
}
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
// entry fields (low / high / root) — S12 direct numeric entry. Clicking a field focuses it
// (entryField_) and typed text commits via parseNoteEntry on Enter.
const int legendTop = stripArea.bottom + 8;
Rect infoR{stripArea.left, legendTop, stripArea.right, legendTop + 18};
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
kitText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary);
// Three fields laid out left-to-right after the sample label. A focused field lifts to
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
const Rect fields = noteEntryFieldsArea(bands);
const char* names[3] = {"Low", "High", "Root"};
const std::string vals[3] = {
noteLabel(z.lowNote), noteLabel(z.highNote),
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
for (int f = 0; f < 3; ++f) {
const Rect fr = noteEntryFieldRect(fields, f);
const bool editing = (entryField_ == f);
fillSurface(bmp, toKitBox(fr), Role::BgCell,
editing ? InteractionState::Focus : InteractionState::Rest);
const KitColor border =
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
toLice(border), 1.0f, 0);
std::string cap = std::string(names[f]) + ": " +
(editing ? (entryText_ + "_") : vals[f]);
kitText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(),
Font::ValueMono, Role::TextPrimary);
}
} else if (map_.zones.empty()) {
kitText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.",
Font::Label, Role::TextDim);
}
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
// pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
// single capture is already a one-zone map — one storage site serves both).
const bool haveControlTarget =
(selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) ||
(map_.zones.empty() && !selectedId_.empty());
if (haveControlTarget) {
paintControls(bmp, zonesControlPanel(computeBands(w, h)));
}
}
// The label + the two toggle-segment captions for a control (member so it can name the private
// ParamControl enum). Segments are only read for a ControlKind::Toggle.
namespace {
struct ControlLabels { const char* label; const char* seg0; const char* seg1; };
} // namespace
void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) {
// Resolve the play params: from the selected zone when one is chosen, or from the
// PerformanceZone product defaults when the map is empty but a capture is picked
// (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map;
// see paintZones for the gate that reaches here).
ZonePlaySeconds play;
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
} else if (map_.zones.empty() && !selectedId_.empty()) {
play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR)
} else {
return; // no control target
}
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const auto labelsFor = [](ParamControl c) -> ControlLabels {
switch (c) {
case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"};
case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"};
case ParamControl::kAttack: return {"Attack", "", ""};
case ParamControl::kHold: return {"Hold", "", ""};
case ParamControl::kDecay: return {"Decay", "", ""};
case ParamControl::kSustain: return {"Sustain", "", ""};
case ParamControl::kRelease: return {"Release", "", ""};
case ParamControl::kTrigLength: return {"Length %", "", ""};
case ParamControl::kTrigFadeIn: return {"Fade in", "", ""};
case ParamControl::kTrigFadeOut: return {"Fade out", "", ""};
case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"};
case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""};
case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""};
case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""};
default: return {"", "", ""};
}
};
for (const ControlRow& r : rows) {
if (r.row.top >= panel.bottom) break; // clip at the panel bottom
const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim);
const double v = controlValue(r.id, play);
const bool hov = isHovered(HoverKind::kControl, r.id);
if (r.kind == ControlKind::Toggle) {
const bool seg1 = (v >= 0.5);
const Rect s0 = toggleSegmentRect(r.control, 0);
const Rect s1 = toggleSegmentRect(r.control, 1);
// The lit segment carries the primary accent (Active); the unlit segment hovers
// toward accent/hot when the whole control is under the pointer.
const InteractionState s0State =
!seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
const InteractionState s1State =
seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State);
fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State);
kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary);
} else {
// Track groove (recessed cell) + accent filled portion up to the handle + a raised
// handle. Dragging THIS control brightens the fill/handle (accent/hot).
const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id);
const Rect track = sliderTrackRect(r.control);
fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
track.right, track.top + track.height() / 2 + 1}),
Role::BgCell, InteractionState::Pressed);
const Rect handle = sliderHandleRect(r.control, v);
// Filled portion: track-left to the handle center.
const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
if (fillW > 0) {
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)),
1.0f, 0);
}
const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
handle.bottom - 2})};
drawButton(bmp, knob, nullptr,
dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest),
/*warn=*/false);
}
}
}
// --- Hover resolution (Phase L, L3) ------------------------------------------
//
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline).
// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only
// the frequently-touched interactive surfaces light on hover; a purely decorative region
// resolves to kNone (clearing any prior hover). Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
HoverTarget h; // kNone by default
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
const EditorBands bands = computeBands(w, hgt);
if (contains(bands.toggleBrowser, x, y)) {
h = {HoverKind::kToggleBrowser, -1};
} else if (contains(bands.toggleZones, x, y)) {
h = {HoverKind::kToggleZones, -1};
} else 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 fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
if (contains(searchAbs, x, y)) {
h = {HoverKind::kSearchBox, -1};
} else {
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int bx = x - browserArea.left;
const int by = y - browserArea.top;
const int tabCount = static_cast<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
const int card = (tab >= 0)
? -1
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (tab >= 0) {
h = {HoverKind::kFilterTab, tab};
} else if (card >= 0) {
h = {HoverKind::kCard, card};
} else if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
const ChannelToggleRects chan = channelToggleRects(area);
if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
}
}
} else { // 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};
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (contains(addR, x, y)) {
h = {HoverKind::kAddZone, -1};
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else {
// The param control panel (a selected zone, or the single-capture face's defaults).
ZonePlaySeconds play;
bool haveTarget = false;
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
haveTarget = true;
} else if (map_.zones.empty() && !selectedId_.empty()) {
play = PerformanceZone{}.play;
haveTarget = true;
}
if (haveTarget) {
const Rect panel = zonesControlPanel(bands);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
}
}
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) 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);
// 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 fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below.
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
if (contains(searchAbs, x, y)) {
searchFocused_ = true;
invalidate();
return;
}
searchFocused_ = false; // any other browser click defocuses the search box
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.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;
}
// S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the
// grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then.
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height() > 0 &&
contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top,
thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) {
drag_ = DragKind::kScrollThumb;
dragStartY_ = y;
dragStartScrollOffset_ = scrollOffset_;
return;
}
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The
// hit-test adds the scroll offset back so a scrolled card maps to the right index.
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
return;
}
// The setup band: the mono/stereo toggle (header row), the S11 waveform markers,
// then the root-marker strip.
if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
// S7: a click on a channel-mode segment sets the instance mode (setChannelMode
// re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored
// by the processor). Snapshot the new mode locally so the paint reflects it at once.
const ChannelToggleRects chan = channelToggleRects(area);
if (contains(chan.mono, x, y)) {
channelMode_ = ChannelMode::Mono;
processor_->setChannelMode(ChannelMode::Mono);
invalidate();
return;
}
if (contains(chan.stereo, x, y)) {
channelMode_ = ChannelMode::Stereo;
processor_->setChannelMode(ChannelMode::Stereo);
invalidate();
return;
}
// S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the
// waveform band first (it sits above the keyboard strip). markerAtPoint resolves
// which marker under the grab; a miss falls through to the keyboard strip.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
if (frames > 0) {
const Rect waveArea = setupWaveformArea(area);
const SetupMarkers m = pickedMarkers(frames);
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
if (hit >= 0) {
drag_ = DragKind::kWaveMarker;
waveMarker_ = static_cast<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartMap_ = map_;
return; // no immediate set — the marker only moves once the cursor drags
}
}
// The setup strip: grab the root marker (drag to set the picked capture's root).
const Rect stripArea = setupStripArea(area);
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;
dragStartMap_ = map_;
// 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;
}
// 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)) {
// Add a full-keyboard zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
// already exists, select it rather than appending a duplicate (mirrors the upsert the
// root-marker drag path already performs, preventing overlapping identical zones).
std::string seed = !selectedId_.empty() ? selectedId_
: (!visible_.empty() ? visible_.front().id : std::string());
if (seed.empty()) return;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
selectedZone_ = i;
invalidate();
return;
}
}
PerformanceZone z;
z.sampleId = seed;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
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;
commitAndReload();
return;
}
// 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 Rect stripArea = zonesStripArea(bands);
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;
dragStartMap_ = map_;
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;
}
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();
}
return;
}
// S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
// zone is selected. entryText_ starts empty (the user types the full value).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect fields = noteEntryFieldsArea(bands);
for (int f = 0; f < 3; ++f) {
if (contains(noteEntryFieldRect(fields, f), x, y)) {
entryField_ = f;
entryText_.clear();
invalidate();
return;
}
}
}
entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry
// The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab
// starts a live drag (commit on release). Reachable for an explicit zone selection OR for
// the single-capture face when the map is empty but a capture is picked (S15-F2 lean).
// In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first
// control interaction (same path as "+ Add Zone"), then apply the control — the zone is
// committed as part of the control edit.
if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) {
// Synthesize a probe layout with the product defaults to see if the click is in the
// panel before committing to creating the zone.
const Rect panel = zonesControlPanel(bands);
const ZonePlaySeconds defaultPlay = PerformanceZone{}.play;
const std::vector<ControlDesc> probeDescs = controlDescs(defaultPlay);
const std::vector<ControlRow> probeRows = layoutControls(panel, probeDescs);
if (controlAtPoint(probeRows, x, y) >= 0) {
// The click lands in the control panel — materialize the zone now.
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = 0;
// Fall through to the control handler below which will process the click.
}
}
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const Rect panel = zonesControlPanel(bands);
const std::vector<ControlDesc> descs = controlDescs(z.play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) {
// Find the row to know its kind + control rect.
for (const ControlRow& r : rows) {
if (r.id != id) continue;
if (r.kind == ControlKind::Toggle) {
const int seg = toggleSegmentHitTest(r.control, x, y);
if (seg >= 0) {
applyControl(id, z.play, 0.0, seg);
commitAndReload(); // a toggle is a discrete, final edit
}
} else {
// Grab the slider: set the value at the grab x immediately, then live-drag.
drag_ = DragKind::kParamSlider;
dragParamId_ = id;
dragParamPanel_ = panel;
dragStartMap_ = map_;
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
invalidate(); // live feedback; commit on WM_LBUTTONUP
}
break;
}
}
}
}
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 = setupStripArea(area);
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.
// Upsert the root override on the picked id; track the zone index so the control panel
// stays visible after the zone is materialized on the single-capture face (fix: without
// setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls).
bool found = false;
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == selectedId_) {
z.rootOverride = note;
selectedZone_ = i;
found = true;
break;
}
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.rootOverride = note;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
}
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
return;
}
if (drag_ == DragKind::kWaveMarker) {
// S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap
// it against the decoded PCM, apply the inter-marker clamps, and write the override live.
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 waveArea = setupWaveformArea(area);
const std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
// requirement). Pure over the cached mono frames — no host types, no file I/O.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
// map hides controls after the first marker drag on the single-capture face).
selectedZone_ = upsertPickedOverride(m);
invalidate();
return;
}
if (drag_ == DragKind::kScrollThumb) {
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
// window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser
// when a capture is picked (the setup band takes the bottom).
const int dyThumb = y - dragStartY_;
const bool havePick = !selectedId_.empty();
const int setupTop = havePick
? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top);
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
dragStartScrollOffset_, dyThumb);
invalidate();
return;
}
if (drag_ == DragKind::kParamSlider) {
// S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live
// track rect (the panel geometry is stable during the drag; re-laying keeps the value
// mapping exact even if a mode toggle changed the row set — it did not, mid-drag).
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const std::vector<ControlDesc> descs = controlDescs(z.play);
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
for (const ControlRow& r : rows) {
if (r.id == dragParamId_) {
applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0);
break;
}
}
invalidate();
return;
}
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(bands);
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;
const DragKind kind = drag_;
drag_ = DragKind::kNone;
// A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other
// drag is a coherent map edit: publish the in-flight map + reload off-thread on release.
if (kind == DragKind::kScrollThumb) {
invalidate();
return;
}
commitAndReload();
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls
// roughly one card row; the offset is clamped at paint (the layout/panel height is known
// there). A positive delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowser) return;
const int rows = delta / 120;
if (rows == 0) return;
scrollOffset_ -= rows * kBrowserCardHeight;
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
invalidate();
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
// focused, owns the keystrokes).
if (view_ == View::kZones && entryField_ >= 0) {
if (ch == 13) { // Enter: parse + commit
if (auto note = parseNoteEntry(entryText_)) {
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
else z.rootOverride = *note;
commitAndReload();
}
}
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 27) { // Escape cancels
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 8) { // backspace
if (!entryText_.empty()) entryText_.pop_back();
invalidate();
} else if (ch >= 32 && ch < 127) {
entryText_.push_back(static_cast<char>(ch));
invalidate();
}
return;
}
// S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search).
if (view_ != View::kBrowser || !searchFocused_) return;
if (ch == 8) { // backspace
if (!searchQuery_.empty()) searchQuery_.pop_back();
} else if (ch == 27) { // escape clears + defocuses
searchQuery_.clear();
searchFocused_ = false;
} else if (ch >= 32 && ch < 127) {
searchQuery_.push_back(static_cast<char>(ch));
} else {
return; // ignore other control chars
}
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
rebuildVisible();
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact
// ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff
// decision point), so we do NOT ingest the dropped files and — load-bearing — NEVER insert a
// timeline item. Instead of silently swallowing the drop, flash a clear affordance pointing
// at the shipped ingest gesture. dropHintTicks_ counts sync ticks (kSyncTimerIntervalMs
// each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer decays it to 0.
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
dropHintTicks_ = 6;
#ifdef _WIN32
invalidate();
#endif
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self =
reinterpret_cast<ReaSamplerEditor*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
switch (msg) {
case WM_PAINT: {
PAINTSTRUCT ps{};
HDC hdc = BeginPaint(hwnd, &ps);
if (self) self->paint(hdc);
EndPaint(hwnd, &ps);
return 0;
}
case WM_LBUTTONDOWN:
if (self) {
SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child
SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12)
self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
}
return 0;
case WM_MOUSEMOVE:
if (self) {
const int mx = GET_X_LPARAM(lParam);
const int my = GET_Y_LPARAM(lParam);
// Hover feedback (Phase L, L3): resolve the element under the pointer and
// repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover
// clears when the pointer leaves the child (TrackMouseEvent is one-shot).
if (!self->mouseTracking_) {
TRACKMOUSEEVENT tme{};
tme.cbSize = sizeof(tme);
tme.dwFlags = TME_LEAVE;
tme.hwndTrack = hwnd;
TrackMouseEvent(&tme);
self->mouseTracking_ = true;
}
// While a drag is in flight the drag owns the surface; skip hover resolution
// (a hover repaint mid-drag would fight the live drag feedback).
if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my);
self->onMouseMove(mx, my);
}
return 0;
case WM_MOUSELEAVE:
if (self) {
self->mouseTracking_ = false;
if (self->hover_.kind != HoverKind::kNone) {
self->hover_ = HoverTarget{};
self->invalidate();
}
}
return 0;
case WM_MOUSEWHEEL:
// S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up.
if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam));
return 0;
case WM_CHAR:
// S12 type-to-filter search keystrokes (only acted on when the search box is focused).
if (self) self->onSearchChar(static_cast<unsigned int>(wParam));
return 0;
case WM_GETDLGCODE:
// Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search).
return DLGC_WANTCHARS | DLGC_WANTARROWS;
case WM_LBUTTONUP:
if (self) {
self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
ReleaseCapture();
}
return 0;
case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
if (self && self->drag_ != DragKind::kNone) {
// A scrollbar drag is transient (no map mutation + dragStartMap_ was not
// snapshotted for it) — reset the drag state only, never touch map_. Every
// map-editing drag rolls its live mutation back to the pre-grab snapshot.
if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->invalidate();
}
return 0;
case WM_DROPFILES: {
// S13 (relay degraded): count the dropped files and flash the affordance. We do NOT
// read/ingest the paths (the instrument never ingests — the relay to the extension is
// unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner.
HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
DragFinish(drop);
if (self) self->onFilesDropped(static_cast<int>(count));
return 0;
}
case WM_TIMER:
if (self && wParam == kSyncTimerId) self->onSyncTimer();
return 0;
case WM_ERASEBKGND:
return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase
default:
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
}
#else // non-Windows: not a build target (D5), but keep the TU compilable.
void ReaSamplerEditor::attachedToParent() {}
void ReaSamplerEditor::removedFromParent() {}
tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) {
return CPluginView::onSize(newSize);
}
#endif // _WIN32
} // namespace reasampler::vst