3025 lines
153 KiB
C++
3025 lines
153 KiB
C++
// 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 <cstdio> // snprintf (Phase S voice-count readout)
|
||
#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 "curve_popup.h" // r11 centered curve-popup sheet geometry (FB1)
|
||
#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 "master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1)
|
||
#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3)
|
||
#include "note_entry.h" // S12 direct numeric note-entry parse
|
||
#include "param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta)
|
||
#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 "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3)
|
||
#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 / knob_deck). r11 Sample face (top->bottom): a TITLE band
|
||
// (name + Browse/Zone nav buttons), the FULL-WIDTH ELASTIC HERO (absorbs all height left after
|
||
// the fixed bands, floor kHeroMinHeight — the S11 markers + the S-VIEW-3 envelope overlay trace
|
||
// over it), the ROOT + PREVIEW CLUSTER (remainder-width root strip + preview-trigger + radial
|
||
// velocity knob + mini curve-preview button + Mono/Stereo), and the bottom-anchored KNOB DECK
|
||
// (the fenced control groups — the r11 replacement for the slider control strip). Browse + Zone
|
||
// reuse the browser grid / zone strip machinery unchanged.
|
||
constexpr int kTitleHeight = 26;
|
||
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
|
||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
||
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
|
||
constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||
|
||
// 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: one bin per drawn pixel column — the
|
||
// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins.
|
||
// thumbnailFor clamps the request to the decoded frame count.
|
||
int thumbBins(const BrowserLayout& layout) {
|
||
return (std::max)(1, kWaveformOversample *
|
||
waveformColumnCount(toKitBox(cardThumbnailRect(layout, 0))));
|
||
}
|
||
#endif
|
||
} // namespace
|
||
|
||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||
: CPluginView(nullptr), processor_(processor) {
|
||
// Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample
|
||
// home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate
|
||
// mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll
|
||
// on a 1080p screen with headroom. Wide enough that the control strip's label + value columns
|
||
// read comfortably.
|
||
ViewRect r(0, 0, 840, 620);
|
||
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();
|
||
voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot
|
||
voiceMode_ = processor_->voiceMode();
|
||
monoTrigger_ = processor_->monoTrigger();
|
||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||
// r11: a refresh that emptied the selection (a bank change on the sync tick) closes the
|
||
// curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible
|
||
// modal would swallow clicks.
|
||
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
|
||
// FB2: on the Zone surface the popup edits the SELECTED zone; if the refresh dropped the
|
||
// selection (the zones list shrank), close it rather than let it retarget another zone.
|
||
if (view_ == View::kZone && selectedZone_ < 0) curvePopupOpen_ = false;
|
||
// 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
|
||
}
|
||
|
||
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||
// Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous
|
||
// sample's materialized full-range zone must not linger — first-match resolve would
|
||
// keep playing it while the editor draws the new pick's zone (matched by sampleId,
|
||
// order-blind). Authored Zone-view maps (any narrow key range) are left untouched.
|
||
selectedId_ = id;
|
||
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||
}
|
||
commitAndReload();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
|
||
// The picked id's one-zone override, if the map already carries one; else a product-default
|
||
// zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes
|
||
// it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean.
|
||
for (const PerformanceZone& z : map_.zones) {
|
||
if (z.sampleId == selectedId_) return z;
|
||
}
|
||
PerformanceZone z;
|
||
z.sampleId = selectedId_;
|
||
z.lowNote = 0;
|
||
z.highNote = 127;
|
||
return z;
|
||
}
|
||
|
||
int ReaSamplerEditor::effectiveRoot() const {
|
||
int root = 60;
|
||
for (const SampleChoice& s : samples_) {
|
||
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
|
||
}
|
||
for (const PerformanceZone& z : map_.zones) {
|
||
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
|
||
}
|
||
return root;
|
||
}
|
||
|
||
int ReaSamplerEditor::ensureSampleZone() {
|
||
if (selectedId_.empty()) return -1;
|
||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
|
||
}
|
||
PerformanceZone z;
|
||
z.sampleId = selectedId_;
|
||
z.lowNote = 0;
|
||
z.highNote = 127;
|
||
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
|
||
constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%)
|
||
|
||
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||
} // namespace
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
double ReaSamplerEditor::liveSampleRate() const {
|
||
return processor_ ? processor_->sampleRate() : 0.0;
|
||
}
|
||
|
||
double ReaSamplerEditor::previewVelocity01() const {
|
||
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
|
||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||
}
|
||
|
||
// --- r11 knob-deck plumbing (FB1) ---------------------------------------------
|
||
|
||
namespace {
|
||
// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck order.
|
||
enum DeckGroup {
|
||
kGroupAmpEnv = 0,
|
||
kGroupPitch,
|
||
kGroupPitchEnv,
|
||
kGroupVoice,
|
||
kGroupMaster,
|
||
};
|
||
} // namespace
|
||
|
||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||
// The PER-ZONE groups — the deck grammar both surfaces share (FB2: the Zone panel renders
|
||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs).
|
||
// Group widths are MODE-INDEPENDENT: AMP ENVELOPE reserves its 5-cell Gate width (Trigger
|
||
// leaves two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows
|
||
// the neighbouring groups (r11).
|
||
std::vector<DeckGroupDesc> out;
|
||
{
|
||
DeckGroupDesc amp;
|
||
amp.id = kGroupAmpEnv;
|
||
amp.captionWidth = 78;
|
||
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
|
||
if (play.playMode == PlayMode::Gate) {
|
||
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
|
||
static_cast<int>(ParamControl::kHold),
|
||
static_cast<int>(ParamControl::kDecay),
|
||
static_cast<int>(ParamControl::kSustain),
|
||
static_cast<int>(ParamControl::kRelease)};
|
||
} else {
|
||
// Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out —
|
||
// matches the drawn envelope), plus the two reserved blanks.
|
||
amp.cellIds = {static_cast<int>(ParamControl::kTrigFadeIn),
|
||
static_cast<int>(ParamControl::kTrigLength),
|
||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
||
}
|
||
out.push_back(std::move(amp));
|
||
}
|
||
{
|
||
DeckGroupDesc pitch;
|
||
pitch.id = kGroupPitch;
|
||
pitch.captionWidth = 38;
|
||
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
|
||
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
|
||
out.push_back(std::move(pitch));
|
||
}
|
||
{
|
||
DeckGroupDesc penv;
|
||
penv.id = kGroupPitchEnv;
|
||
penv.captionWidth = 58;
|
||
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
|
||
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
|
||
static_cast<int>(ParamControl::kPitchEnvDecay),
|
||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
||
out.push_back(std::move(penv));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||
// groups. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the
|
||
// post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays
|
||
// OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs.
|
||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||
{
|
||
DeckGroupDesc voice;
|
||
voice.id = kGroupVoice;
|
||
voice.captionWidth = 38;
|
||
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
|
||
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
|
||
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
|
||
out.push_back(std::move(voice));
|
||
}
|
||
{
|
||
DeckGroupDesc master;
|
||
master.id = kGroupMaster;
|
||
master.captionWidth = 46;
|
||
master.cellIds = {static_cast<int>(ParamControl::kMasterGain)};
|
||
out.push_back(std::move(master));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
|
||
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
|
||
switch (static_cast<ParamControl>(id)) {
|
||
case ParamControl::kKeyTrack:
|
||
return clamp01(zone.keyTrack / kKeyTrackMax);
|
||
case ParamControl::kVoiceCount:
|
||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||
case ParamControl::kMasterGain:
|
||
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
||
default:
|
||
return controlValue(id, zone.play);
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||
if (!processor_) return;
|
||
norm = clamp01(norm);
|
||
if (id == -2) {
|
||
// Preview velocity: live processor write (persisted per-instance; the setter clamps
|
||
// to MIDI 1..127 so the knob's bottom still strikes audibly).
|
||
processor_->setPreviewVelocity(static_cast<std::uint8_t>(norm * 127.0 + 0.5));
|
||
return;
|
||
}
|
||
switch (static_cast<ParamControl>(id)) {
|
||
case ParamControl::kVoiceCount: {
|
||
// Stepped: quantize the continuous drag to the integer count and track it live
|
||
// for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on
|
||
// WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild,
|
||
// not thirty.
|
||
const int count =
|
||
kMinVoiceCount +
|
||
static_cast<int>(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5);
|
||
voiceCount_ = count;
|
||
return;
|
||
}
|
||
case ParamControl::kMasterGain:
|
||
// Post-mixer gain: one atomic store; the audio thread picks it up next block.
|
||
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
|
||
return;
|
||
default:
|
||
applyZoneControl(zoneIndex, id, norm, 0);
|
||
return;
|
||
}
|
||
}
|
||
|
||
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
|
||
char buf[24];
|
||
buf[0] = '\0';
|
||
const ZonePlaySeconds& play = zone.play;
|
||
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||
case ParamControl::kAttack:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
|
||
case ParamControl::kHold:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break;
|
||
case ParamControl::kDecay:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break;
|
||
case ParamControl::kSustain:
|
||
snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break;
|
||
case ParamControl::kRelease:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break;
|
||
case ParamControl::kTrigLength:
|
||
snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break;
|
||
case ParamControl::kTrigFadeIn:
|
||
snprintf(buf, sizeof(buf), "%lldf",
|
||
static_cast<long long>(play.trigger.fadeInFrames)); break;
|
||
case ParamControl::kTrigFadeOut:
|
||
snprintf(buf, sizeof(buf), "%lldf",
|
||
static_cast<long long>(play.trigger.fadeOutFrames)); break;
|
||
case ParamControl::kPitchEnvAttack:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break;
|
||
case ParamControl::kPitchEnvDecay:
|
||
snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break;
|
||
case ParamControl::kPitchEnvDepth:
|
||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
|
||
case ParamControl::kKeyTrack:
|
||
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
|
||
case ParamControl::kVoiceCount:
|
||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
||
case ParamControl::kMasterGain:
|
||
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
|
||
default:
|
||
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
|
||
break;
|
||
}
|
||
return std::string(buf);
|
||
}
|
||
|
||
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||
// Match the control-panel sliders' own domains so a node drag can never produce a param a
|
||
// slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the
|
||
// Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use).
|
||
EnvClampBounds b;
|
||
b.maxAttackSeconds = kEnvTimeMaxSeconds;
|
||
b.maxHoldSeconds = kEnvTimeMaxSeconds;
|
||
b.maxDecaySeconds = kEnvTimeMaxSeconds;
|
||
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
|
||
b.maxFadeInFraction = 1.0;
|
||
b.maxFadeOutFraction = 1.0;
|
||
b.maxLengthFraction = 1.0;
|
||
return b;
|
||
}
|
||
|
||
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||
std::int64_t startFrame) const {
|
||
AmpEnvelope env;
|
||
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
||
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
|
||
env.attackSeconds = play.adsr.attackSeconds;
|
||
env.holdSeconds = play.adsr.holdSeconds;
|
||
env.decaySeconds = play.adsr.decaySeconds;
|
||
env.sustainLevel = play.adsr.sustainLevel;
|
||
env.releaseSeconds = play.adsr.releaseSeconds;
|
||
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
|
||
// span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start
|
||
// point so the fraction denominator matches the voice's actual post-start span. A zero play
|
||
// length yields 0 fractions.
|
||
env.lengthFraction = play.trigger.lengthFraction;
|
||
const std::int64_t playLen =
|
||
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
||
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
|
||
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
|
||
return env;
|
||
}
|
||
|
||
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
|
||
std::int64_t startFrame, ZonePlaySeconds& play) const {
|
||
if (env.mode == EnvMode::Gate) {
|
||
play.adsr.attackSeconds = env.attackSeconds;
|
||
play.adsr.holdSeconds = env.holdSeconds;
|
||
play.adsr.decaySeconds = env.decaySeconds;
|
||
play.adsr.sustainLevel = env.sustainLevel;
|
||
play.adsr.releaseSeconds = env.releaseSeconds;
|
||
} else {
|
||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
||
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
|
||
// effective start point so the frame denominator matches the voice's actual post-start span.
|
||
// Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length
|
||
// trigger never plays nothing.
|
||
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||
const std::int64_t playLen =
|
||
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
|
||
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
|
||
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
|
||
}
|
||
}
|
||
|
||
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()) {
|
||
// Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with
|
||
// trailing empty {0,0} bins, which would render a very short sample as a comb of
|
||
// spikes over flat gaps.
|
||
const std::size_t bins =
|
||
(std::min)(static_cast<std::size_t>((std::max)(1, binCount)), mono.size());
|
||
env = computeEnvelope(mono, 1, mono.size(), bins);
|
||
}
|
||
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 460 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×460 keeps the Sample face's title
|
||
// + hero waveform + cluster + a few control rows visible (the control strip clips gracefully
|
||
// below the panel bottom); anything smaller would clip essential UI. The default 840×620 is
|
||
// above this floor.
|
||
constexpr int kMinW = 560;
|
||
constexpr int kMinH = 460;
|
||
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 Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts
|
||
// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope
|
||
// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band
|
||
// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views
|
||
// derive their own areas from `title` + `content` below.
|
||
namespace {
|
||
constexpr int kPad = 8;
|
||
|
||
// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the curve
|
||
// in the POPUP (curve_popup), each summoned from its own mini preview button — the Sample
|
||
// cluster's and the Zone panel's (the inline Zone box is retired). The INSET keeps node handles
|
||
// + the pick radius inside the border so an endpoint at amp 0/1 stays grabbable — the ONE
|
||
// curveBoxFromRect grammar the popup derives its mapping box through.
|
||
constexpr int kVelCurveInset = 14; // border -> mapping-box inset: caption band (~12px) + 2px gap
|
||
constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete
|
||
|
||
// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial
|
||
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
|
||
constexpr int kPreviewBtnW = 64;
|
||
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar)
|
||
constexpr int kCurveBtnSize = 28; // the square curve-preview button
|
||
|
||
struct SampleBands {
|
||
Rect title; // top: name + Browse/Zone nav buttons
|
||
Rect navBrowse; // the "Browse" title-band button
|
||
Rect navZone; // the "Zone" title-band button
|
||
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay (r11)
|
||
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
|
||
Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap)
|
||
};
|
||
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed
|
||
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-
|
||
// anchored). When the window is too short for the floor (below the checkSizeConstraint
|
||
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
|
||
// window bottom gracefully.
|
||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
||
SampleBands b;
|
||
const int titleH = (std::min)(kTitleHeight, h);
|
||
b.title = Rect{0, 0, w, titleH};
|
||
// Two nav buttons right-anchored in the title band (Browse then Zone).
|
||
const int navTop = 2;
|
||
const int navBot = (std::max)(navTop, titleH - 2);
|
||
const Rect zone{w - kPad - kNavButtonWidth, navTop, w - kPad, navBot};
|
||
const Rect browse{zone.left - 4 - kNavButtonWidth, navTop, zone.left - 4, navBot};
|
||
b.navBrowse = browse;
|
||
b.navZone = zone;
|
||
|
||
int deckTop = h - kPad - deckH;
|
||
int clusterTop = deckTop - kClusterHeight - 4;
|
||
int heroBottom = clusterTop - 4;
|
||
if (heroBottom - titleH < kHeroMinHeight) {
|
||
heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below
|
||
clusterTop = heroBottom + 4;
|
||
deckTop = clusterTop + kClusterHeight + 4;
|
||
}
|
||
b.hero = Rect{kPad, titleH, w - kPad, heroBottom};
|
||
b.cluster = Rect{0, clusterTop, w, clusterTop + kClusterHeight};
|
||
b.deck = Rect{kPad, deckTop, w - kPad, deckTop + deckH};
|
||
return b;
|
||
}
|
||
|
||
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the right
|
||
// side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve preview
|
||
// button 28 · Mono|Stereo). Draw + hit-test both derive from this ONE formula.
|
||
struct ClusterRects {
|
||
Rect rootStrip; // remainder-width fenced root strip
|
||
Rect preview; // the preview-trigger button
|
||
Rect velCell; // the radial preview-velocity knob cell (knob + label band)
|
||
Rect velKnob; // the 28px knob square at the cell's top
|
||
Rect velLabel; // the 12px label band beneath it
|
||
Rect curveBtn; // the mini curve-preview button (opens the popup)
|
||
};
|
||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) {
|
||
ClusterRects r;
|
||
const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2;
|
||
const int stripBot = stripTop + kStripBandHeight;
|
||
const int curveTop = cluster.top + (cluster.height() - kCurveBtnSize) / 2;
|
||
r.curveBtn = Rect{chanMono.left - kPad - kCurveBtnSize, curveTop,
|
||
chanMono.left - kPad, curveTop + kCurveBtnSize};
|
||
r.velCell = Rect{r.curveBtn.left - kPad - kVelCellW, stripTop,
|
||
r.curveBtn.left - kPad, stripBot};
|
||
const int knobLeft = r.velCell.left + (kVelCellW - kDeckKnobSize) / 2;
|
||
r.velKnob = Rect{knobLeft, r.velCell.top, knobLeft + kDeckKnobSize,
|
||
r.velCell.top + kDeckKnobSize};
|
||
r.velLabel = Rect{r.velCell.left, r.velKnob.bottom, r.velCell.right, r.velCell.bottom};
|
||
r.preview = Rect{r.velCell.left - kPad - kPreviewBtnW, stripTop,
|
||
r.velCell.left - kPad, stripBot};
|
||
r.rootStrip = Rect{cluster.left + kPad, stripTop, r.preview.left - kPad, stripBot};
|
||
return r;
|
||
}
|
||
|
||
// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance
|
||
// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula.
|
||
Rect zonesStripArea(const Rect& content) {
|
||
const int stripTop = content.top + 4 + 20 + 12; // addR.bottom + 12
|
||
return Rect{content.left + kPad, stripTop, content.right - kPad,
|
||
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 Rect& content) {
|
||
const int stripBottom = zonesStripArea(content).bottom;
|
||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8)
|
||
return Rect{content.left + 8 + 128, top, 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 Rect& content) {
|
||
const Rect strip = zonesStripArea(content);
|
||
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
|
||
return Rect{content.left + kPad, panelTop, content.right - kPad,
|
||
content.bottom - 4};
|
||
}
|
||
|
||
// FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the
|
||
// Sample face uses. The deck lays out from the panel top (top-anchored — the Zone panel reads
|
||
// top-down, unlike the Sample face's bottom-anchored band), with a column at the panel's right
|
||
// reserved for the mini curve-preview button so a deck row can never collide with it (the pure
|
||
// knob_deck wrap keeps whole groups inside availWidth). Both draw + hit-test derive from these
|
||
// two formulas so they never drift.
|
||
Rect zonesDeckArea(const Rect& content) {
|
||
const Rect panel = zonesControlPanel(content);
|
||
return Rect{panel.left, panel.top, panel.right - kCurveBtnSize - kPad, panel.bottom};
|
||
}
|
||
// The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample
|
||
// cluster's button): the cluster's 28px square, right-anchored at the panel top.
|
||
Rect zonesCurveButton(const Rect& content) {
|
||
const Rect panel = zonesControlPanel(content);
|
||
return Rect{panel.right - kCurveBtnSize, panel.top, panel.right, panel.top + kCurveBtnSize};
|
||
}
|
||
|
||
// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and
|
||
// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the
|
||
// Box through this ONE formula, so drawn nodes and grabs can never drift apart.
|
||
VelocityCurve::Box curveBoxFromRect(const Rect& r) {
|
||
return VelocityCurve::Box{r.left + kVelCurveInset, r.top + kVelCurveInset,
|
||
(std::max)(0, r.width() - 2 * kVelCurveInset),
|
||
(std::max)(0, r.height() - 2 * kVelCurveInset)};
|
||
}
|
||
|
||
// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is
|
||
// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored
|
||
// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide,
|
||
// kChanSegH tall, side by side.
|
||
constexpr int kChanSegW = 52;
|
||
constexpr int kChanSegH = 18;
|
||
struct ChannelToggleRects { Rect mono; Rect stereo; };
|
||
ChannelToggleRects channelToggleRects(const Rect& area) {
|
||
const int top = area.top + (area.height() - kChanSegH) / 2;
|
||
const int right = area.right - kPad;
|
||
const Rect stereo{right - kChanSegW, top, right, top + kChanSegH};
|
||
const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
|
||
return {mono, stereo};
|
||
}
|
||
|
||
// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle map;
|
||
// this turns it into LICE calls through the kit's palette roles. LICE's arc convention matches
|
||
// param_slider's (angle 0 = 12 o'clock, positive clockwise: point = (cx + r*sin(a), cy -
|
||
// r*cos(a)), verified in vendor/WDL lice_arc.cpp) — but LICE takes RADIANS, and drawing the
|
||
// 7->5 o'clock sweep THROUGH the top needs a continuous angle span, so the degrees convert as
|
||
// (deg - 360) * pi/180, mapping 210..510 onto -150..+150 degrees. One conversion, both arcs.
|
||
void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01,
|
||
InteractionState st) {
|
||
const KnobGeometry kg = computeKnob(knobRect);
|
||
if (kg.radius <= 1.0) return;
|
||
constexpr double kDegToRad = 3.14159265358979323846 / 180.0;
|
||
const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep
|
||
const float cx = static_cast<float>(kg.centerX);
|
||
const float cy = static_cast<float>(kg.centerY);
|
||
const float rOuter = static_cast<float>(kg.radius) - 0.5f;
|
||
const bool disabled = (st == InteractionState::Disabled);
|
||
const bool hot = (st == InteractionState::Dragging || st == InteractionState::Hover);
|
||
|
||
// Face: a filled circle in the cell surface color under the interaction state.
|
||
LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(roleColorState(Role::BgCell, st)),
|
||
1.0f, 0, true);
|
||
// Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare).
|
||
const float a0 = static_cast<float>((arc.startDeg - 360.0) * kDegToRad);
|
||
const float a1 = static_cast<float>((arc.startDeg + knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||
LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, true);
|
||
// Value arc: start -> the value's angle, in the live accent (hot while under the pointer /
|
||
// dragging, dim when disabled).
|
||
const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01);
|
||
if (v > 0.0) {
|
||
const float av = static_cast<float>(
|
||
(arc.startDeg + v * knobSweepDeg(arc) - 360.0) * kDegToRad);
|
||
const Role valueRole = disabled ? Role::TextDim
|
||
: (hot ? Role::AccentHot : Role::AccentPrimary);
|
||
LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(roleColor(valueRole)), 1.0f, 0, true);
|
||
}
|
||
// Needle: from ~35% radius out to the rim at the value's angle.
|
||
const KnobPoint tip = knobNeedlePoint(kg, arc, v);
|
||
const float ix = cx + static_cast<float>((tip.x - kg.centerX) * 0.35);
|
||
const float iy = cy + static_cast<float>((tip.y - kg.centerY) * 0.35);
|
||
const Role needleRole = disabled ? Role::TextDim : Role::TextPrimary;
|
||
LICE_Line(bmp, static_cast<int>(ix + 0.5f), static_cast<int>(iy + 0.5f),
|
||
static_cast<int>(tip.x + 0.5f), static_cast<int>(tip.y + 0.5f),
|
||
toLice(roleColor(needleRole)), 1.0f, 0, true);
|
||
}
|
||
|
||
// 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. S-VIEW-7: OVERLAY the two-tone piano-key pattern —
|
||
// naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a
|
||
// dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without
|
||
// counting. The pattern is an OVERLAY (not a keyboard shape) per the spec.
|
||
const LICE_pixel darkKey = toLice(roleColor(Role::BgBase));
|
||
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);
|
||
if (!isNaturalKey(n)) {
|
||
// Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key
|
||
// pattern reads while the spectral tint still shows through.
|
||
LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 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)));
|
||
|
||
// S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn
|
||
// OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||
// modal reads as a sheet layered over the home face (the "picker over the document" grammar).
|
||
if (view_ == View::kZone) {
|
||
paintZone(&bmp, w, h);
|
||
} else {
|
||
paintSample(&bmp, w, h);
|
||
if (view_ == View::kBrowse) paintBrowse(&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 whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||
if (dropHintTicks_ > 0) {
|
||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||
Rect banner{0, bannerTop, w, bannerTop + 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);
|
||
}
|
||
|
||
// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared
|
||
// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav.
|
||
namespace {
|
||
void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) {
|
||
fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest);
|
||
Rect titleText{title.left + 8, title.top, title.right - 8, title.bottom};
|
||
kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary);
|
||
}
|
||
} // namespace
|
||
|
||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||
// r11: the deck height comes from the pure knob_deck wrap (mode-independent — the AMP
|
||
// ENVELOPE group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||
const PerformanceZone deckZone = effectiveSampleZone();
|
||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
|
||
const SampleBands bands =
|
||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||
|
||
// Title: 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.
|
||
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]";
|
||
}
|
||
drawTitleBand(bmp, bands.title, title);
|
||
|
||
// Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap
|
||
// surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw
|
||
// it Active (accent-primary) so it reads as "start here".
|
||
const bool empty = selectedId_.empty() && map_.zones.empty();
|
||
{
|
||
const KitButtonBox box{toKitBox(bands.navBrowse)};
|
||
const InteractionState st = empty ? InteractionState::Active
|
||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||
}
|
||
{
|
||
const KitButtonBox box{toKitBox(bands.navZone)};
|
||
const InteractionState st =
|
||
isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||
drawButton(bmp, box, "Zone", st, /*warn=*/false);
|
||
}
|
||
|
||
// Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing
|
||
// at Browse (which is lit above). No hero waveform / controls to draw.
|
||
if (empty) {
|
||
Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.deck.bottom};
|
||
paintEmptyState(bmp, body);
|
||
return;
|
||
}
|
||
|
||
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
|
||
// else the product-default play params (S15-F2 — the single capture is a one-zone map). This
|
||
// is the ONE storage site both Sample and Zone edit.
|
||
const PerformanceZone& zone = deckZone;
|
||
|
||
// --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay -----------
|
||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||
const Rect waveArea = bands.hero;
|
||
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
|
||
if (frames > 0 && waveArea.width() > 0) {
|
||
// FA3 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||
const std::int64_t wantBins =
|
||
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
|
||
kWaveformOversample;
|
||
const std::size_t bins =
|
||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
|
||
drawEnvelope(bmp, waveArea, env);
|
||
|
||
const SetupMarkers m = pickedMarkers(frames);
|
||
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);
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
|
||
// S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero.
|
||
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
|
||
} else {
|
||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||
}
|
||
|
||
// --- Root + preview cluster (r11: remainder-width root strip, preview button, radial
|
||
// velocity knob, mini curve-preview button, channel toggle) -----------------------------
|
||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono);
|
||
int root = effectiveRoot();
|
||
if (cr.rootStrip.width() > 0) {
|
||
drawSpectralStrip(bmp, cr.rootStrip);
|
||
const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height());
|
||
drawRootMarker(bmp, cr.rootStrip, sl, root);
|
||
}
|
||
|
||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||
{
|
||
const KitButtonBox box{toKitBox(cr.preview)};
|
||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||
}
|
||
// Preview velocity: a RADIAL knob cell (r11 — the deck cell grammar), bound to the same
|
||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||
{
|
||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||
const InteractionState st = dragging ? InteractionState::Dragging
|
||
: (hov ? InteractionState::Hover
|
||
: InteractionState::Rest);
|
||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||
if (dragging || hov) {
|
||
char buf[8];
|
||
snprintf(buf, sizeof(buf), "%d",
|
||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||
} else {
|
||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||
}
|
||
}
|
||
// The mini curve-preview button (r11): opens the popup editor. Shared painter with the
|
||
// Zone panel's button (FB2 — one grammar on both surfaces).
|
||
paintCurveButton(bmp, cr.curveBtn, zone);
|
||
// Mono | Stereo output-mode 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);
|
||
}
|
||
|
||
// --- The knob deck (r11: the fenced control groups, bottom-anchored) -------------------
|
||
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
|
||
|
||
// --- The curve popup (r11): a centered sheet over the whole Sample face, drawn LAST ----
|
||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||
}
|
||
|
||
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
|
||
const PerformanceZone& zone, std::int64_t frames) {
|
||
if (frames <= 0 || waveArea.width() <= 0 || waveArea.height() <= 0) return;
|
||
const double rate = liveSampleRate();
|
||
if (rate <= 0.0) return;
|
||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||
|
||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||
const int x0 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i - 1].x));
|
||
const int x1 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i].x));
|
||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||
}
|
||
// Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw-
|
||
// only). Lit accent-hot when this node is the grabbed one. FA2 guarantees every vertex is
|
||
// in-bounds (the pre-FA2 right-edge clip is dead and removed — edge nodes like ReleaseEnd
|
||
// at area.right-1 MUST get handles); the handle SQUARE is additionally clamped inside the
|
||
// hero rect so a 6px box on an edge node never overhangs into the neighbouring bands.
|
||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||
for (const EnvVertex& v : poly) {
|
||
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
|
||
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
|
||
const int r = 3;
|
||
const int hx = (std::max)(waveArea.left + r, (std::min)(waveArea.right - 1 - r, v.x));
|
||
const int hy = (std::max)(waveArea.top + r, (std::min)(waveArea.bottom - 1 - r, v.y));
|
||
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||
const PerformanceZone& zone) {
|
||
if (r.width() <= 0 || r.height() <= 0) return; // defensive (degenerate rect)
|
||
|
||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (FB2: the
|
||
// popup is the only host).
|
||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1,
|
||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||
|
||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||
if (box.width <= 0 || box.height <= 1) return;
|
||
const VelocityCurve& curve = zone.velocityCurve;
|
||
|
||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
|
||
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
|
||
// the hit-test all share one coordinate system.
|
||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||
int prevX = 0, prevY = 0;
|
||
for (int px = 0; px <= box.width; ++px) {
|
||
const int cx = box.left + px;
|
||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||
prevX = cx;
|
||
prevY = cy;
|
||
}
|
||
|
||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||
// has passed kCurveDragOffMargin outside the box — release will delete the node).
|
||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
|
||
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
|
||
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.left == r.left &&
|
||
dragCurveRect_.top == r.top) &&
|
||
(dragCurX_ < r.left - kCurveDragOffMargin ||
|
||
dragCurX_ > r.right + kCurveDragOffMargin ||
|
||
dragCurY_ < r.top - kCurveDragOffMargin ||
|
||
dragCurY_ > r.bottom + kCurveDragOffMargin);
|
||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||
curvePointIndex_ == static_cast<int>(i));
|
||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||
: (hot ? handleHot : handle);
|
||
const int nr = 3;
|
||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||
const PerformanceZone& zone,
|
||
const std::vector<DeckGroupDesc>& descs) {
|
||
if (deckArea.width() <= 0 || deckArea.height() <= 0) return;
|
||
const DeckLayout dl = layoutDeck(descs, deckArea.left, deckArea.top, deckArea.width());
|
||
const ZonePlaySeconds& play = zone.play;
|
||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||
|
||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||
bool seg1Active, bool disabled) {
|
||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||
const InteractionState st0 =
|
||
disabled ? InteractionState::Disabled
|
||
: (!seg1Active ? InteractionState::Active
|
||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||
const InteractionState st1 =
|
||
disabled ? InteractionState::Disabled
|
||
: (seg1Active ? InteractionState::Active
|
||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||
disabled ? Role::TextDim
|
||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||
disabled ? Role::TextDim
|
||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||
};
|
||
|
||
// The knob's short name label (swapped for the live value during hover/drag — r11: no
|
||
// third line, no permanent value clutter).
|
||
const auto knobName = [](ParamControl c) -> const char* {
|
||
switch (c) {
|
||
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::kTrigFadeIn: return "Fade In";
|
||
case ParamControl::kTrigLength: return "Len %";
|
||
case ParamControl::kTrigFadeOut: return "Fade Out";
|
||
case ParamControl::kKeyTrack: return "Key Trk";
|
||
case ParamControl::kPitchEnvAttack: return "P.Att";
|
||
case ParamControl::kPitchEnvDecay: return "P.Dec";
|
||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||
case ParamControl::kVoiceCount: return "Voices";
|
||
case ParamControl::kMasterGain: return "Gain";
|
||
default: return "";
|
||
}
|
||
};
|
||
|
||
for (const DeckGroupLayout& g : dl.groups) {
|
||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||
LICE_DrawRect(bmp, g.box.left, g.box.top, g.box.width() - 1, g.box.height() - 1,
|
||
hairline, 1.0f, 0);
|
||
const char* caption = "";
|
||
switch (g.id) {
|
||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||
case kGroupPitch: caption = "PITCH"; break;
|
||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||
case kGroupVoice: caption = "VOICE"; break;
|
||
case kGroupMaster: caption = "MASTER"; break;
|
||
default: break;
|
||
}
|
||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||
|
||
// The compact caption toggle (r11: right-anchored IN the caption row, never full-width).
|
||
if (g.captionToggle.id >= 0) {
|
||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||
case ParamControl::kPlayMode:
|
||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||
play.playMode == PlayMode::Trigger, false);
|
||
break;
|
||
case ParamControl::kPitchEngine:
|
||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||
play.pitchEngine == PitchEngine::Preserve, false);
|
||
break;
|
||
case ParamControl::kPitchEnvEnable:
|
||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||
break;
|
||
case ParamControl::kVoiceMode:
|
||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||
break;
|
||
default: break;
|
||
}
|
||
}
|
||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
||
if (g.rowToggle.id >= 0) {
|
||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||
}
|
||
|
||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||
// stable geometry (r11).
|
||
for (const DeckCellLayout& c : g.cells) {
|
||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||
const InteractionState st =
|
||
disabled ? InteractionState::Disabled
|
||
: (dragging ? InteractionState::Dragging
|
||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||
drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st);
|
||
const std::string label = (dragging || hov)
|
||
? deckValueLabel(c.id, zone)
|
||
: std::string(knobName(static_cast<ParamControl>(c.id)));
|
||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||
}
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||
const PerformanceZone& zone) {
|
||
// The mini curve-preview button (r11/FB2 — shared by the Sample cluster and the Zone
|
||
// panel): a hairline-bordered bg/cell square with the zone's live velocity curve traced
|
||
// in miniature (no node markers at this scale). Hover lifts it; it draws ACTIVE
|
||
// (accent-primary border) while its popup is open, and re-renders live as the popup
|
||
// edits the curve (same zone, re-read each paint).
|
||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
||
: roleColor(Role::LineHairline);
|
||
LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, toLice(border), 1.0f, 0);
|
||
const VelocityCurve& curve = zone.velocityCurve;
|
||
const int inset = 3;
|
||
const VelocityCurve::Box mini{r.left + inset, r.top + inset, r.width() - 2 * inset,
|
||
r.height() - 2 * inset};
|
||
if (mini.width > 1 && mini.height > 1) {
|
||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
||
int prevX = 0, prevY = 0;
|
||
for (int px = 0; px <= mini.width; ++px) {
|
||
const int mx = mini.left + px;
|
||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||
prevX = mx;
|
||
prevY = my;
|
||
}
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
|
||
// Sample face stays legible behind it), then the centered sheet.
|
||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||
LICE_DrawRect(bmp, pl.sheet.left, pl.sheet.top, pl.sheet.width() - 1,
|
||
pl.sheet.height() - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
||
{
|
||
const KitButtonBox box{toKitBox(pl.close)};
|
||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||
? InteractionState::Hover
|
||
: InteractionState::Rest;
|
||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||
}
|
||
// The full-size editor: ONE draw path + the one curveBoxFromRect mapping formula, so
|
||
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
|
||
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
|
||
// surface (FB2).
|
||
paintVelocityCurve(bmp, pl.curveBox, popupZone());
|
||
}
|
||
|
||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||
// The zone the popup displays: the Zone surface's SELECTED zone (FB2), else the Sample
|
||
// face's one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||
}
|
||
return effectiveSampleZone();
|
||
}
|
||
|
||
int ReaSamplerEditor::popupZoneIndex() {
|
||
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
|
||
// Zone surface never materializes (the button only shows for an explicit selection); the
|
||
// Sample face finds-or-materializes the picked id's one-zone site.
|
||
if (view_ == View::kZone) {
|
||
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
|
||
? selectedZone_
|
||
: -1;
|
||
}
|
||
return ensureSampleZone();
|
||
}
|
||
|
||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||
// The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home
|
||
// (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash
|
||
// click dismiss (outside only when no drag is in flight, per the spec); in-box clicks
|
||
// route to the shared curve machinery against popupZoneIndex(); anything else on the
|
||
// sheet is swallowed.
|
||
if (!curvePopupOpen_) return false;
|
||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||
if (contains(pl.close, x, y)) {
|
||
curvePopupOpen_ = false;
|
||
invalidate();
|
||
return true;
|
||
}
|
||
if (contains(pl.curveBox, x, y)) {
|
||
const int zi = popupZoneIndex();
|
||
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
|
||
return true;
|
||
}
|
||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||
curvePopupOpen_ = false;
|
||
invalidate();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
|
||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||
if (box.width <= 0 || box.height <= 1) return;
|
||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||
|
||
int idx = z.velocityCurve.pointAtPixel(box, x, y);
|
||
|
||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||
selectedZone_ = zoneIndex;
|
||
commitAndReload();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
|
||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||
dragStartMap_ = map_;
|
||
|
||
// Empty-space click inside the MAPPING BOX: add a control point at the cursor via the pure
|
||
// inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller
|
||
// gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the
|
||
// caption band — must not add a point; a click there would clamp to velocity 0/127 and
|
||
// produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab
|
||
// an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is
|
||
// handled above; only the add path is box-gated here.
|
||
if (idx < 0) {
|
||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||
y >= box.top && y < box.top + box.height);
|
||
if (inBox) {
|
||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
|
||
}
|
||
}
|
||
|
||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||
|
||
drag_ = DragKind::kCurveNode;
|
||
curvePointIndex_ = idx;
|
||
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
|
||
dragCurveRect_ = r;
|
||
dragCurveZone_ = zoneIndex;
|
||
dragStartX_ = x;
|
||
dragStartY_ = y;
|
||
selectedZone_ = zoneIndex;
|
||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box,
|
||
// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker
|
||
// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this
|
||
// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over.
|
||
namespace {
|
||
struct BrowseModal {
|
||
Rect title;
|
||
Rect back; // the "Back" title-band button
|
||
Rect search; // the type-to-filter box (absolute)
|
||
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin
|
||
Rect cancel; // footer Cancel
|
||
Rect confirm; // footer Load (confirm)
|
||
};
|
||
constexpr int kBrowseFooterH = 30;
|
||
BrowseModal computeBrowseModal(int w, int h) {
|
||
BrowseModal m;
|
||
const int titleH = (std::min)(kTitleHeight, h);
|
||
m.title = Rect{0, 0, w, titleH};
|
||
m.back = Rect{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)};
|
||
// Search box below the title, spanning the width (searchBoxRect lays it out from 0).
|
||
const Rect sb = searchBoxRect(w);
|
||
m.search = Rect{kPad, titleH, w - kPad, titleH + sb.height()};
|
||
const int footerTop = (std::max)(m.search.bottom, h - kBrowseFooterH);
|
||
m.content = Rect{0, m.search.bottom, w, footerTop};
|
||
// Footer: Cancel (left) + Load (right).
|
||
const int fTop = footerTop + 3;
|
||
const int fBot = (std::max)(fTop, h - 3);
|
||
m.cancel = Rect{kPad, fTop, kPad + 90, fBot};
|
||
m.confirm = Rect{w - kPad - 90, fTop, w - kPad, fBot};
|
||
return m;
|
||
}
|
||
} // namespace
|
||
|
||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||
// A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying
|
||
// Sample face with a bg/base wash, then draw the picker opaque on top.
|
||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0);
|
||
const BrowseModal bm = computeBrowseModal(w, h);
|
||
|
||
// Title band + Back button (returns to Sample, discarding any pending pick).
|
||
drawTitleBand(bmp, bm.title, "Browse - pick a capture");
|
||
{
|
||
const KitButtonBox box{toKitBox(bm.back)};
|
||
const InteractionState st =
|
||
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||
drawButton(bmp, box, "Back", st, /*warn=*/false);
|
||
}
|
||
|
||
// Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover.
|
||
const Rect searchAbs = bm.search;
|
||
const InteractionState searchState =
|
||
searchFocused_ ? InteractionState::Focus
|
||
: (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
|
||
: InteractionState::Rest);
|
||
fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
|
||
if (searchFocused_) {
|
||
LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1,
|
||
searchAbs.height() - 1, toLice(roleColor(Role::TextPrimary)), 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);
|
||
}
|
||
|
||
// Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset).
|
||
const Rect browserArea = bm.content;
|
||
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_);
|
||
|
||
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 (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_)
|
||
// is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border.
|
||
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) {
|
||
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 pending = (s.id == browsePendingId_);
|
||
const bool loaded = (s.id == selectedId_);
|
||
const InteractionState cardState =
|
||
isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest;
|
||
fillSurface(bmp, toKitBox(content), Role::BgCell, cardState);
|
||
const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary)
|
||
: (loaded ? roleColor(Role::AccentTertiary)
|
||
: 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));
|
||
|
||
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);
|
||
}
|
||
|
||
// Scrollbar thumb.
|
||
{
|
||
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 (visible_.empty()) paintEmptyState(bmp, browserArea);
|
||
|
||
// Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert
|
||
// (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar.
|
||
Rect footer{0, bm.content.bottom, w, h};
|
||
fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest);
|
||
{
|
||
const KitButtonBox box{toKitBox(bm.cancel)};
|
||
const InteractionState st =
|
||
isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||
drawButton(bmp, box, "Cancel", st, /*warn=*/false);
|
||
}
|
||
{
|
||
const KitButtonBox box{toKitBox(bm.confirm)};
|
||
const bool armed = !browsePendingId_.empty();
|
||
const InteractionState st = armed
|
||
? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active)
|
||
: InteractionState::Rest;
|
||
drawButton(bmp, box, "Load", st, /*warn=*/false);
|
||
}
|
||
}
|
||
|
||
// The Zone-view (S-VIEW-8) content area: the whole window below the title band.
|
||
namespace {
|
||
Rect zoneContentArea(int w, int h) {
|
||
const int titleH = (std::min)(kTitleHeight, h);
|
||
return Rect{0, titleH, w, h};
|
||
}
|
||
} // namespace
|
||
|
||
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
|
||
// to the Sample home on close.
|
||
const Rect title{0, 0, w, (std::min)(kTitleHeight, h)};
|
||
drawTitleBand(bmp, title, "Zone - keyboard map");
|
||
{
|
||
const Rect back{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom - 2)};
|
||
const KitButtonBox box{toKitBox(back)};
|
||
const InteractionState st =
|
||
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||
drawButton(bmp, box, "Back", st, /*warn=*/false);
|
||
}
|
||
|
||
const Rect content = zoneContentArea(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{content.left + pad, content.top + 4, content.left + pad + 96,
|
||
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 Sample face, 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(content);
|
||
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(content);
|
||
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 per-zone parameter surface for the selected zone. FB2 (R11-F2): the SAME knob deck +
|
||
// curve-preview-button/popup grammar as the Sample face — one control language over the one
|
||
// storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. Only
|
||
// the per-zone groups render here; VOICE/MASTER are per-instance (ComponentState) and live
|
||
// on the Sample deck only.
|
||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||
}
|
||
|
||
// The curve popup (FB2): a centered sheet over the whole Zone surface, drawn LAST —
|
||
// the same modal grammar as the Sample face.
|
||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||
}
|
||
|
||
// --- 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;
|
||
|
||
if (view_ == View::kBrowse) {
|
||
const BrowseModal bm = computeBrowseModal(w, hgt);
|
||
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
|
||
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
|
||
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
|
||
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
|
||
else {
|
||
const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height());
|
||
const int bx = x - bm.content.left;
|
||
const int by = y - bm.content.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 (curvePopupOpen_) { // the r11 curve popup — modal over Sample AND Zone (FB2)
|
||
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
|
||
if (contains(pl.close, x, y)) {
|
||
h = {HoverKind::kPopupClose, -1};
|
||
} else if (contains(pl.curveBox, x, y)) {
|
||
// A curve node under the pointer lights accent-hot.
|
||
const int idx =
|
||
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
|
||
}
|
||
} else if (view_ == View::kZone) {
|
||
const Rect back{w - kPad - kNavButtonWidth, 2,
|
||
w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)};
|
||
const Rect content = zoneContentArea(w, hgt);
|
||
Rect addR{content.left + kPad, content.top + 4, content.left + kPad + 96, content.top + 4 + 20};
|
||
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
|
||
if (contains(back, x, y)) {
|
||
h = {HoverKind::kBack, -1};
|
||
} else if (contains(addR, x, y)) {
|
||
h = {HoverKind::kAddZone, -1};
|
||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||
h = {HoverKind::kDeleteZone, -1};
|
||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||
// FB2: the per-zone knob deck + the mini curve-preview button (the Sample deck's
|
||
// hover grammar — knobs light + swap label->value).
|
||
if (contains(zonesCurveButton(content), x, y)) {
|
||
h = {HoverKind::kCurveButton, -1};
|
||
} else {
|
||
const ZonePlaySeconds& play =
|
||
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||
const Rect deckArea = zonesDeckArea(content);
|
||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left,
|
||
deckArea.top, deckArea.width());
|
||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||
}
|
||
}
|
||
} else { // Sample view (home, r11 recomposition)
|
||
const PerformanceZone zone = effectiveSampleZone();
|
||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||
const SampleBands bands =
|
||
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
|
||
if (contains(bands.navBrowse, x, y)) {
|
||
h = {HoverKind::kNavBrowse, -1};
|
||
} else if (contains(bands.navZone, x, y)) {
|
||
h = {HoverKind::kNavZone, -1};
|
||
} else if (selectedId_.empty() && map_.zones.empty()) {
|
||
// Empty state — no interactive surfaces beyond the nav.
|
||
} else {
|
||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono);
|
||
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
|
||
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
|
||
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
|
||
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
|
||
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
|
||
else if (contains(bands.deck, x, y)) {
|
||
// A deck knob/toggle under the pointer: knobs light + swap label->value.
|
||
const DeckLayout dl =
|
||
layoutDeck(descs, bands.deck.left, bands.deck.top, bands.deck.width());
|
||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.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;
|
||
|
||
// ---- Browse modal (S-VIEW-5): pick + confirm/cancel over the Sample face ----
|
||
if (view_ == View::kBrowse) {
|
||
const BrowseModal bm = computeBrowseModal(w, h);
|
||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||
// Cancel/Back: discard the pending pick, return to Sample unchanged.
|
||
browsePendingId_.clear();
|
||
searchFocused_ = false;
|
||
view_ = View::kSample;
|
||
invalidate();
|
||
return;
|
||
}
|
||
if (contains(bm.confirm, x, y)) {
|
||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||
if (!browsePendingId_.empty()) {
|
||
loadSelection(browsePendingId_);
|
||
}
|
||
browsePendingId_.clear();
|
||
searchFocused_ = false;
|
||
view_ = View::kSample;
|
||
invalidate();
|
||
return;
|
||
}
|
||
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
|
||
searchFocused_ = false;
|
||
|
||
const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height());
|
||
const int bx = x - bm.content.left;
|
||
const int by = y - bm.content.top;
|
||
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;
|
||
}
|
||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||
if (thumb.height() > 0 &&
|
||
contains(Rect{thumb.left + bm.content.left, thumb.top + bm.content.top,
|
||
thumb.right + bm.content.left, thumb.bottom + bm.content.top}, x, y)) {
|
||
drag_ = DragKind::kScrollThumb;
|
||
dragStartY_ = y;
|
||
dragStartScrollOffset_ = scrollOffset_;
|
||
return;
|
||
}
|
||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||
if (card >= 0) {
|
||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||
loadSelection(id);
|
||
browsePendingId_.clear();
|
||
lastBrowseClickCard_ = -1;
|
||
searchFocused_ = false;
|
||
view_ = View::kSample;
|
||
invalidate();
|
||
} else {
|
||
browsePendingId_ = id;
|
||
lastBrowseClickCard_ = card;
|
||
invalidate();
|
||
}
|
||
return;
|
||
}
|
||
lastBrowseClickCard_ = -1;
|
||
return;
|
||
}
|
||
|
||
// ---- Sample home (S-VIEW-2 / r11) ----
|
||
if (view_ == View::kSample) {
|
||
// r11 curve popup: while open the sheet is modal — it owns every left-click.
|
||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||
|
||
const PerformanceZone probeZone = effectiveSampleZone();
|
||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
|
||
const SampleBands bands =
|
||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||
if (contains(bands.navBrowse, x, y)) {
|
||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||
// capture reads as pre-selected.
|
||
browsePendingId_ = selectedId_;
|
||
lastBrowseClickCard_ = -1;
|
||
view_ = View::kBrowse;
|
||
invalidate();
|
||
return;
|
||
}
|
||
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
|
||
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
|
||
|
||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono);
|
||
|
||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||
// (momentary — note-on on press, note-off on release).
|
||
if (contains(cr.preview, x, y)) {
|
||
const int note = effectiveRoot();
|
||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||
previewingNote_ = note;
|
||
processor_->previewNoteOn(note);
|
||
invalidate();
|
||
return;
|
||
}
|
||
// Radial preview-velocity knob (r11): GRAB-ANCHORED vertical drag — the grab itself
|
||
// never jumps the value (FA4); the delta from the grab point maps via knobDragValue.
|
||
if (contains(cr.velCell, x, y)) {
|
||
drag_ = DragKind::kDeckKnob;
|
||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||
dragParamZone_ = -1;
|
||
dragKnobStartValue_ = previewVelocity01();
|
||
dragStartX_ = x;
|
||
dragStartY_ = y;
|
||
invalidate();
|
||
return;
|
||
}
|
||
// The mini curve-preview button: summon the popup editor.
|
||
if (contains(cr.curveBtn, x, y)) {
|
||
curvePopupOpen_ = true;
|
||
invalidate();
|
||
return;
|
||
}
|
||
// Channel toggle.
|
||
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;
|
||
}
|
||
|
||
// The knob deck (r11): toggles commit at once (a discrete, final edit — the slider
|
||
// precedent); knobs start a grab-anchored vertical drag. The deck band swallows its
|
||
// clicks (no fall-through to the hero/markers).
|
||
if (contains(bands.deck, x, y)) {
|
||
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.left, bands.deck.top,
|
||
bands.deck.width());
|
||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||
switch (static_cast<ParamControl>(hit.id)) {
|
||
case ParamControl::kVoiceMode: {
|
||
// Processor-side per-instance param: live setter (engine rebuild via
|
||
// the drain-slot swap — tails survive), local snapshot in step.
|
||
const VoiceMode m =
|
||
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||
if (m != voiceMode_) {
|
||
voiceMode_ = m;
|
||
processor_->setVoiceMode(m);
|
||
}
|
||
invalidate();
|
||
break;
|
||
}
|
||
case ParamControl::kMonoTrigger: {
|
||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||
const MonoTrigger t =
|
||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||
if (t != monoTrigger_) {
|
||
monoTrigger_ = t;
|
||
processor_->setMonoTrigger(t);
|
||
}
|
||
invalidate();
|
||
break;
|
||
}
|
||
default: {
|
||
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
|
||
// materialize the one-zone site, apply, commit.
|
||
const int zi = ensureSampleZone();
|
||
if (zi >= 0) {
|
||
applyZoneControl(zi, hit.id, 0.0, hit.segment);
|
||
selectedZone_ = zi;
|
||
commitAndReload();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (hit.kind == DeckHitKind::Knob) {
|
||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
||
const bool pitchEnvKnob =
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
|
||
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
|
||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
|
||
// Processor-side knobs: transient live writes, no map edit, no reload.
|
||
drag_ = DragKind::kDeckKnob;
|
||
dragParamId_ = hit.id;
|
||
dragParamZone_ = -1;
|
||
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
|
||
} else {
|
||
// Zone-param knobs: live-drag the map, commit on release.
|
||
const int zi = ensureSampleZone();
|
||
if (zi < 0) return;
|
||
drag_ = DragKind::kDeckKnob;
|
||
dragParamId_ = hit.id;
|
||
dragParamZone_ = zi;
|
||
selectedZone_ = zi;
|
||
dragStartMap_ = map_;
|
||
dragKnobStartValue_ =
|
||
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
|
||
}
|
||
dragStartX_ = x;
|
||
dragStartY_ = y;
|
||
invalidate();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
|
||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||
const Rect waveArea = bands.hero;
|
||
if (frames > 0) {
|
||
const double rate = liveSampleRate();
|
||
if (rate > 0.0) {
|
||
const PerformanceZone zone = effectiveSampleZone();
|
||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
||
if (nh.hit) {
|
||
drag_ = DragKind::kEnvNode;
|
||
envNode_ = nh.node;
|
||
dragStartX_ = x;
|
||
dragStartY_ = y;
|
||
dragStartEnv_ = env;
|
||
dragSampleFrames_ = frames;
|
||
dragStartFrame_ = startFrame;
|
||
dragStartMap_ = map_;
|
||
return; // node moves once the cursor drags
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Fenced root strip: grab the root marker (remainder-width since r11).
|
||
if (cr.rootStrip.width() > 0) {
|
||
const StripLayout sl = layoutStrip(cr.rootStrip.width(), cr.rootStrip.height());
|
||
const int note = keyAtPoint(sl, x - cr.rootStrip.left, y - cr.rootStrip.top);
|
||
if (note >= 0) {
|
||
drag_ = DragKind::kRootMarker;
|
||
dragStartX_ = x;
|
||
dragStartRoot_ = note;
|
||
dragStartMap_ = map_;
|
||
onMouseMove(x, y); // apply the click as the first delta==0 set
|
||
return;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ---- Zone surface (S-VIEW-8 / FB2) ----
|
||
// The curve popup is modal over the Zone surface too (FB2) — it owns every click while
|
||
// open, checked before every Zone affordance (incl. Back).
|
||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||
const Rect back{w - kPad - kNavButtonWidth, 2,
|
||
w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)};
|
||
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
|
||
const Rect content = zoneContentArea(w, h);
|
||
const int pad = 8;
|
||
Rect addR{content.left + pad, content.top + 4, content.left + pad + 96,
|
||
content.top + 4 + 20};
|
||
if (contains(addR, x, y)) {
|
||
// Add a narrow default 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 (pre-fix bleed survivor), select it rather than appending a duplicate
|
||
// (mirrors the upsert the root-marker drag path already performs).
|
||
// NARROW DEFAULT: seed [root-6, root+5] (one octave centred on the bank root, clamped
|
||
// to [0,127]) so the new zone is immediately "authored" (narrow) and survives
|
||
// reconcileSingleCaptureZones without being treated as a Sample-face full-range zone.
|
||
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;
|
||
}
|
||
}
|
||
// Look up the seed's root note from the browser list (absent root defaults to 60).
|
||
int seedRoot = 60;
|
||
for (const SampleChoice& sc : samples_) {
|
||
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
|
||
}
|
||
const int lo = (std::max)(0, seedRoot - 6);
|
||
const int hi = (std::min)(127, seedRoot + 5);
|
||
PerformanceZone z;
|
||
z.sampleId = seed;
|
||
z.lowNote = lo;
|
||
z.highNote = hi;
|
||
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(content);
|
||
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(content);
|
||
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 Zone view cancels an in-progress entry
|
||
|
||
// The per-zone param surface (FB2): the knob deck + the mini curve-preview button — the
|
||
// SAME grammar and hit-test machinery as the Sample face. Only when a zone is selected
|
||
// (the Zone surface has no single-capture fallback — that lives on the Sample face).
|
||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||
if (contains(zonesCurveButton(content), x, y)) {
|
||
curvePopupOpen_ = true;
|
||
invalidate();
|
||
return;
|
||
}
|
||
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||
const Rect deckArea = zonesDeckArea(content);
|
||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.left, deckArea.top,
|
||
deckArea.width());
|
||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
|
||
// final edit committed at once (the deck precedent). No per-instance ids reach
|
||
// here — VOICE/MASTER are not in the zone group set.
|
||
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
|
||
commitAndReload();
|
||
return;
|
||
}
|
||
if (hit.kind == DeckHitKind::Knob) {
|
||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
|
||
// Sample deck's guard, mirrored.
|
||
const bool pitchEnvKnob =
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||
// GRAB-ANCHORED vertical drag (FA4): live-drag the map, commit on release.
|
||
drag_ = DragKind::kDeckKnob;
|
||
dragParamId_ = hit.id;
|
||
dragParamZone_ = selectedZone_;
|
||
dragStartMap_ = map_;
|
||
dragKnobStartValue_ = deckControlNorm(
|
||
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
|
||
dragStartX_ = x;
|
||
dragStartY_ = y;
|
||
invalidate();
|
||
}
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
|
||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||
// keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1.
|
||
z.keyTrack = clamp01(value) * kKeyTrackMax;
|
||
} else {
|
||
applyControl(id, z.play, value, segment);
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||
if (drag_ == DragKind::kNone) return;
|
||
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
|
||
dragCurY_ = y;
|
||
RECT rc{};
|
||
GetClientRect(childHwnd_, &rc);
|
||
const int w = rc.right - rc.left;
|
||
const int h = rc.bottom - rc.top;
|
||
const int dx = x - dragStartX_;
|
||
|
||
if (drag_ == DragKind::kDeckKnob) {
|
||
// r11 radial knob: GRAB-ANCHORED vertical drag — knobDragValue maps the y delta from
|
||
// the value at grab (up = increase), so the value tracks relative motion and never
|
||
// jumps on grab (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||
const int dy = y - dragStartY_;
|
||
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
|
||
invalidate();
|
||
return;
|
||
}
|
||
|
||
// r11: the Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
|
||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||
|
||
if (drag_ == DragKind::kRootMarker) {
|
||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||
// full-keyboard zone carrying the override on the picked id (the D-B override vehicle) —
|
||
// upsert by id so a repeated drag edits the same zone rather than stacking duplicates.
|
||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||
const Rect stripArea = clusterRects(bands.cluster, chan.mono).rootStrip;
|
||
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
|
||
const int note = resolveDragNote(sl, dragStartRoot_, dx);
|
||
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::kEnvNode) {
|
||
// S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through
|
||
// the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_)
|
||
// so the delta is absolute. Materialize the zone if needed (mirror of the marker path).
|
||
const std::int64_t frames = dragSampleFrames_;
|
||
const double rate = liveSampleRate();
|
||
if (frames <= 0 || rate <= 0.0) return;
|
||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||
const int dy = y - dragStartY_;
|
||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
|
||
totalSeconds, envClampBounds(), dx, dy);
|
||
const int zi = ensureSampleZone();
|
||
if (zi >= 0) {
|
||
unpackEnvelope(edited, frames, dragStartFrame_,
|
||
map_.zones[static_cast<std::size_t>(zi)].play);
|
||
selectedZone_ = zi;
|
||
}
|
||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||
return;
|
||
}
|
||
|
||
if (drag_ == DragKind::kCurveNode) {
|
||
// S-VIEW-10: resolve the grabbed control point from the pixel delta through the pure
|
||
// inverse map (box + neighbour-X + endpoint-pin clamps), against the grab-time curve +
|
||
// box (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||
// commit lands on WM_LBUTTONUP.
|
||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||
if (curvePointIndex_ < 0) return;
|
||
const int dy = y - dragStartY_;
|
||
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
|
||
VelocityCurve::resolvePointDrag(dragStartCurve_,
|
||
static_cast<std::size_t>(curvePointIndex_),
|
||
curveBoxFromRect(dragCurveRect_), dx, dy);
|
||
invalidate();
|
||
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 Rect waveArea = bands.hero;
|
||
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 scroll drag
|
||
// only happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||
// at paint from scrollOffset_.
|
||
const int dyThumb = y - dragStartY_;
|
||
const BrowseModal bm = computeBrowseModal(w, h);
|
||
const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height());
|
||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||
dragStartScrollOffset_, dyThumb);
|
||
invalidate();
|
||
return;
|
||
}
|
||
|
||
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
|
||
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
|
||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
|
||
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) {
|
||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||
// This runs regardless of drag state — the preview press does not start a drag.
|
||
if (previewingNote_ >= 0) {
|
||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||
previewingNote_ = -1;
|
||
invalidate();
|
||
}
|
||
if (drag_ == DragKind::kNone) return;
|
||
const DragKind kind = drag_;
|
||
const int paramId = dragParamId_;
|
||
const int curveIdx = curvePointIndex_;
|
||
const int curveZone = dragCurveZone_;
|
||
const Rect curveRect = dragCurveRect_;
|
||
drag_ = DragKind::kNone;
|
||
dragParamId_ = -1;
|
||
dragParamZone_ = -1;
|
||
curvePointIndex_ = -1;
|
||
dragCurveZone_ = -1;
|
||
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
|
||
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
|
||
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
|
||
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
|
||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||
const bool deckTransient =
|
||
kind == DragKind::kDeckKnob &&
|
||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||
if (deckTransient && processor_ &&
|
||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||
processor_->setVoiceCount(voiceCount_);
|
||
invalidate();
|
||
return;
|
||
}
|
||
// S-VIEW-10 drag-off delete: releasing a curve-node drag well OUTSIDE the box removes the
|
||
// dragged point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain
|
||
// move — its amp keeps the last clamped drag value).
|
||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||
curveZone < static_cast<int>(map_.zones.size())) {
|
||
const bool off = x < curveRect.left - kCurveDragOffMargin ||
|
||
x > curveRect.right + kCurveDragOffMargin ||
|
||
y < curveRect.top - kCurveDragOffMargin ||
|
||
y > curveRect.bottom + kCurveDragOffMargin;
|
||
if (off) {
|
||
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
|
||
static_cast<std::size_t>(curveIdx));
|
||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
|
||
}
|
||
}
|
||
commitAndReload();
|
||
}
|
||
|
||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||
// r11 (issue 3c): right-click on a popup curve node deletes it — the PRIMARY delete
|
||
// affordance; Alt-click and drag-off remain as landed alternates. Commits immediately
|
||
// through the same path as Alt-click; deletePoint's endpoint guard makes an endpoint
|
||
// right-click a safe no-op. Right-clicks act ONLY while the popup is open — over the
|
||
// Sample face OR the Zone surface (FB2; nothing else in the editor consumes them) —
|
||
// and never during an in-flight left drag.
|
||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||
if (drag_ != DragKind::kNone) return;
|
||
RECT rc{};
|
||
GetClientRect(childHwnd_, &rc);
|
||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||
if (!contains(pl.curveBox, x, y)) return;
|
||
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
|
||
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
|
||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
|
||
if (idx < 0) return;
|
||
const int zi = popupZoneIndex();
|
||
if (zi < 0) return;
|
||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
|
||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||
selectedZone_ = zi;
|
||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||
commitAndReload();
|
||
}
|
||
}
|
||
|
||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
|
||
// delta (wheel up) scrolls toward the top (smaller offset).
|
||
if (view_ != View::kBrowse) 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) {
|
||
// r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||
// or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search
|
||
// cannot hold focus under it).
|
||
if (curvePopupOpen_ && ch == 27) {
|
||
curvePopupOpen_ = false;
|
||
invalidate();
|
||
return;
|
||
}
|
||
|
||
// S12 numeric note-entry (Zone surface): 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::kZone && 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::kBrowse || !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_RBUTTONDOWN:
|
||
// r11: right-click — the curve popup's primary node-delete affordance (issue 3c).
|
||
// Routed explicitly (the child wndproc historically handled only left-button).
|
||
if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
|
||
return 0;
|
||
case WM_RBUTTONUP:
|
||
return 0; // claimed so the pair never reaches DefWindowProc (no context menu)
|
||
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) {
|
||
// A held preview note must be released here too (peer of WM_LBUTTONUP) — capture
|
||
// loss otherwise leaves the momentary-key voice hung with no note-off.
|
||
if (self->previewingNote_ >= 0) {
|
||
if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_);
|
||
self->previewingNote_ = -1;
|
||
self->invalidate();
|
||
}
|
||
if (self->drag_ != DragKind::kNone) {
|
||
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
|
||
// voice count / master gain) are transient (no map mutation; dragStartMap_
|
||
// not snapshotted) — reset drag state only, never touch map_. Every
|
||
// map-editing drag rolls its live mutation back to the snapshot.
|
||
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
||
(self->drag_ == DragKind::kDeckKnob &&
|
||
(self->dragParamId_ == -2 ||
|
||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
|
||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
|
||
if (!transient) self->map_ = self->dragStartMap_;
|
||
self->drag_ = DragKind::kNone;
|
||
self->dragParamId_ = -1;
|
||
self->dragParamZone_ = -1;
|
||
self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset)
|
||
self->dragCurveZone_ = -1;
|
||
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
|