// 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 #include #include #include #include #include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry #include "capture_browser.h" #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) #include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) #include "editor_geometry.h" // Rect, contains #include "ext_keys.h" #include "keyboard_strip.h" #include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3) #include "note_entry.h" // S12 direct numeric note-entry parse #include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping #include "peaks.h" // computeEnvelope #include "reaper_bridge.h" #include "reasampler_processor.h" #include "app_version.h" // vstPluginName (channel-derived editor title band, S18) #include "sample_map.h" #include "wav_trim.h" // parseWavLayout, extractFloatFrames #include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) #include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) #ifdef _WIN32 #include // GET_X_LPARAM / GET_Y_LPARAM #include // 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). S-VIEW-2 Sample face (top->bottom): a TITLE band (name + // Browse/Zone nav buttons), a HERO WAVEFORM band (enlarged — the Simpler/Phase-Plant hero, with // the S11 markers + the S-VIEW-3 envelope overlay traced over it), a ROOT + PREVIEW cluster // (fenced root strip + preview-trigger + velocity knob + Mono/Stereo), and the CONTROL STRIP (the // param panel + keyTrack). Browse + Zone reuse the browser grid / zone strip machinery. constexpr int kTitleHeight = 26; constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip) constexpr int kClusterHeight = 52; // root strip + preview + 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& samples, const std::string& id) { for (const SampleChoice& c : samples) { if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; } return "?"; } // The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin // per horizontal pixel. int thumbBins(const BrowserLayout& layout) { return (std::max)(1, cardThumbnailRect(layout, 0).width()); } #endif } // namespace ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) : CPluginView(nullptr), processor_(processor) { // Default view size (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{}; banks_ = banksJson ? listBanks(*banksJson) : std::vector{}; selectedId_ = processor_->selectedSampleId(); map_ = processor_->performanceMap(); channelMode_ = processor_->channelMode(); if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; // Drop a filter that names a bank no longer present. if (!activeFilterBankId_.empty()) { bool found = false; for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; if (!found) activeFilterBankId_.clear(); } rebuildVisible(); } void ReaSamplerEditor::rebuildVisible() { // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). visible_.clear(); for (const SampleChoice& s : samples_) { const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; if (!inBank) continue; const std::string& name = s.displayName.empty() ? s.id : s.displayName; if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); } // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it // must not reset the user's scroll here. } #ifdef _WIN32 // Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared // under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes // Windows the only build target, but the TU must still compile elsewhere). void ReaSamplerEditor::onSyncTimer() { // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the // processor (off the audio thread — the poll itself never touches process()). NEVER while a // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. if (!processor_) return; if (drag_ != DragKind::kNone) return; // defer past the in-flight edit // An open editor marks THIS instance the focused assignment target (the thundering-herd // policy — only an editor-open instance applies a pending assign; see the handoff). Pass // true so this instance consumes the request; instances with no editor open do not poll at // all (the timer is bound to the child window), so they never contend for the request. const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); // Re-snapshot the editor's own view only when something changed (a reload from a bank // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. if (r.reloaded || r.applied) { refreshFromBank(); invalidate(); } // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. if (dropHintTicks_ > 0) { --dropHintTicks_; invalidate(); } } #endif // _WIN32 void ReaSamplerEditor::commitAndReload() { // UI thread only. Publish the edited selection + zones to the processor, then rebuild // the instrument off the audio thread (reloadFromBank bakes them into the live Keymap). if (!processor_) return; processor_->setSelectedSampleId(selectedId_); processor_->setPerformanceMap(map_); processor_->reloadFromBank(); #ifdef _WIN32 invalidate(); #endif } ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { SetupMarkers m; // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override // for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic // from the live bank blob (the same path selectSample uses); the override lives in map_. if (processor_) { auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); if (banksJson) { if (auto sel = selectSample(*banksJson, selectedId_)) { if (sel->loop.hasLoop) { m.hasLoop = true; m.loopStart = sel->loop.start; m.loopEnd = sel->loop.end; } } } } // The override (loop + start) on a zone for the picked id supersedes the intrinsic. for (const PerformanceZone& z : map_.zones) { if (z.sampleId != selectedId_) continue; if (z.loopOverride) { m.hasLoop = z.loopOverride->hasLoop; m.loopStart = z.loopOverride->start; m.loopEnd = z.loopOverride->end; } if (z.startPoint) m.start = *z.startPoint; break; } // Default an unset loop's end to the sample length so the loop markers have somewhere sane // to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false; // the markers are still drawn (drag one to CREATE a loop). if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; return m; } int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { // Find-or-append the zone for selectedId_ and write the loop/start override fields. // The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must // be non-empty; callers are responsible for that guard. // Returns the zone index (0-based) so callers can update selectedZone_. SampleLoop loop; loop.hasLoop = m.hasLoop; loop.start = m.loopStart; loop.end = m.loopEnd; for (int i = 0; i < static_cast(map_.zones.size()); ++i) { PerformanceZone& z = map_.zones[static_cast(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(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(map_.zones.size()); ++i) { if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; } PerformanceZone z; z.sampleId = selectedId_; z.lowNote = 0; z.highNote = 127; map_.zones.push_back(z); return static_cast(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 std::vector ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const { std::vector out; // Always: the two mode toggles. out.push_back({static_cast(ParamControl::kPlayMode), ControlKind::Toggle}); out.push_back({static_cast(ParamControl::kPitchEngine), ControlKind::Toggle}); // Mode-relevant amplitude sliders. if (play.playMode == PlayMode::Gate) { out.push_back({static_cast(ParamControl::kAttack), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kHold), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kDecay), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kSustain), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kRelease), ControlKind::Slider}); } else { // Trigger out.push_back({static_cast(ParamControl::kTrigLength), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kTrigFadeIn), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kTrigFadeOut), ControlKind::Slider}); } // The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on). out.push_back({static_cast(ParamControl::kPitchEnvEnable), ControlKind::Toggle}); out.push_back({static_cast(ParamControl::kPitchEnvAttack), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kPitchEnvDecay), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kPitchEnvDepth), ControlKind::Slider}); // S-VIEW-6 key-tracking (0..200%). Lives on PerformanceZone, not ZonePlaySeconds — the shell // reads/writes it against the zone directly (controlValue/applyControl ignore it). Always shown. out.push_back({static_cast(ParamControl::kKeyTrack), ControlKind::Slider}); return out; } double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over // the frames ceiling. Two domains, kept explicit so neither leaks a rate. const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; const auto framesToNorm = [](std::int64_t f) { return clamp01(static_cast(f) / kFadeMaxFrames); }; switch (static_cast(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(clamp01(v) * kFadeMaxFrames + 0.5); }; switch (static_cast(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(kPreviewVelocityDefault) / 127.0; return static_cast(processor_->previewVelocity()) / 127.0; } 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& 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 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 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(size)); if (!f.read(reinterpret_cast(bytes.data()), size)) bytes.clear(); } } const WavLayout layout = parseWavLayout(bytes); if (layout.valid) { std::vector 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& mono = monoPcmFor(sampleId); Envelope env; if (!mono.empty()) { env = computeEnvelope(mono, 1, mono.size(), static_cast((std::max)(1, binCount))); } auto ins = thumbCache_.emplace(key, std::move(env)); return ins.first->second; } ReaSamplerEditor::~ReaSamplerEditor() { #ifdef _WIN32 if (childHwnd_) { DestroyWindow(childHwnd_); childHwnd_ = nullptr; } #endif } tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { #ifdef _WIN32 if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; #endif return kResultFalse; } tresult PLUGIN_API ReaSamplerEditor::canResize() { return kResultTrue; } tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { // Enforce a minimum usable floor: at least 560 wide and 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(systemWindow); if (!parent) return; HINSTANCE hInst = reinterpret_cast(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(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; 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 hero waveform + S-VIEW-3 envelope overlay Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack) }; SampleBands computeSampleBands(int w, int h) { 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 y = titleH; const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH)); b.hero = Rect{kPad, y, w - kPad, y + heroH}; y += heroH; const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y)); b.cluster = Rect{0, y, w, y + clusterH}; y += clusterH; b.control = Rect{kPad, y, w - kPad, h}; return b; } // The fenced root keyboard-strip rect inside the cluster band (S-VIEW-2): the LEFT ~55% of the // cluster, the fenced root affordance promoted from Browse. The preview cluster takes the right. Rect clusterRootStrip(const Rect& cluster) { const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2; const int right = cluster.left + (cluster.width() * 55) / 100; return Rect{cluster.left + kPad, stripTop, right - kPad, stripTop + kStripBandHeight}; } // The preview-trigger button rect (right of the root strip, left of the channel toggle). Rect clusterPreviewButton(const Rect& cluster) { const Rect strip = clusterRootStrip(cluster); const int left = strip.right + kPad; return Rect{left, strip.top, left + 64, strip.bottom}; } // The preview velocity knob rect (a compact horizontal slider next to the preview button). Rect clusterVelocitySlider(const Rect& cluster) { const Rect prev = clusterPreviewButton(cluster); const int left = prev.right + kPad; return Rect{left, prev.top, left + 96, prev.bottom}; } // 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}; } // 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 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(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) { const SampleBands bands = computeSampleBands(w, h); // 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.control.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. PerformanceZone zone = effectiveSampleZone(); // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- const std::vector& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(pcm.size()); const Rect waveArea = bands.hero; fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); if (frames > 0 && waveArea.width() > 0) { const int bins = (std::max)(1, waveArea.width()); const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(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 (fenced root strip, preview button, velocity knob, channel) --- fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); int root = effectiveRoot(); const Rect rootStrip = clusterRootStrip(bands.cluster); drawSpectralStrip(bmp, rootStrip); { const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); drawRootMarker(bmp, rootStrip, sl, root); } // Preview-trigger button (fires the loaded capture at root through the live voice engine). { const Rect prev = clusterPreviewButton(bands.cluster); const KitButtonBox box{toKitBox(prev)}; const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); drawButton(bmp, box, "Preview", st, /*warn=*/false); } // Preview velocity knob (a compact horizontal slider bound to the persisted previewVelocity). { const Rect vs = clusterVelocitySlider(bands.cluster); const double vel01 = previewVelocity01(); const Rect track = sliderTrackRect(vs); fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1, track.right, track.top + track.height() / 2 + 1}), Role::BgCell, InteractionState::Pressed); const Rect handle = sliderHandleRect(vs, vel01); const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left); if (fillW > 0) { LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2, toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); } const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right, handle.bottom - 2})}; drawButton(bmp, knob, nullptr, InteractionState::Rest, /*warn=*/false); Rect velLbl{vs.left, vs.top - 12, vs.right, vs.top}; kitText(bmp, velLbl, "Vel", Font::Micro, Role::TextDim); } // Mono | Stereo output-mode toggle. { const ChannelToggleRects chan = channelToggleRects(bands.cluster); 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 "Modes-and-down" control strip (S-VIEW-2: moved from Zone) -------------------- paintControls(bmp, bands.control, zone); } 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(frames) / rate; const std::int64_t startFrame = zone.startPoint.value_or(0); const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); const std::vector 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. 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; if (v.x < waveArea.left || v.x >= waveArea.right) continue; // clipped node — no handle const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); const int r = 3; LICE_FillRect(bmp, v.x - r, v.y - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); } } 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(visible_.size()), scrollOffset_); const int tabCount = static_cast(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(i - 1)].displayName; const bool active = (i == 0) ? activeFilterBankId_.empty() : (banks_[static_cast(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(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(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(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { const PerformanceZone& z = map_.zones[static_cast(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 S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger + // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the // single capture is already a one-zone map — one storage site serves both). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { paintControls(bmp, zonesControlPanel(content), map_.zones[static_cast(selectedZone_)]); } } // The label + the two toggle-segment captions for a control (member so it can name the private // ParamControl enum). Segments are only read for a ControlKind::Toggle. namespace { struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; } // namespace void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone) { // The control panel edits `zone`: its ZonePlaySeconds (Mode/Pitch/AHDSR-or-Trigger/AD-pitch) // plus the S-VIEW-6 keyTrack scalar (which lives on PerformanceZone, not the play struct — it // is read/written from `zone` directly). Shared by the Sample home face (single-capture, the // effective one-zone site) and the Zone surface (an explicit zone) — one storage site. const ZonePlaySeconds& play = zone.play; const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); const auto labelsFor = [](ParamControl c) -> ControlLabels { switch (c) { case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"}; case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"}; case ParamControl::kAttack: return {"Attack", "", ""}; case ParamControl::kHold: return {"Hold", "", ""}; case ParamControl::kDecay: return {"Decay", "", ""}; case ParamControl::kSustain: return {"Sustain", "", ""}; case ParamControl::kRelease: return {"Release", "", ""}; case ParamControl::kTrigLength: return {"Length %", "", ""}; case ParamControl::kTrigFadeIn: return {"Fade in", "", ""}; case ParamControl::kTrigFadeOut: return {"Fade out", "", ""}; case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"}; case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""}; case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""}; case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""}; case ParamControl::kKeyTrack: return {"Key track", "", ""}; default: return {"", "", ""}; } }; for (const ControlRow& r : rows) { if (r.row.top >= panel.bottom) break; // clip at the panel bottom const ControlLabels lab = labelsFor(static_cast(r.id)); kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim); // kKeyTrack lives on the zone (0..200% over kKeyTrackMax), not in `play` — resolve it // directly; every other control reads through controlValue against the play struct. const double v = (r.id == static_cast(ParamControl::kKeyTrack)) ? clamp01(zone.keyTrack / kKeyTrackMax) : controlValue(r.id, play); const bool hov = isHovered(HoverKind::kControl, r.id); if (r.kind == ControlKind::Toggle) { const bool seg1 = (v >= 0.5); const Rect s0 = toggleSegmentRect(r.control, 0); const Rect s1 = toggleSegmentRect(r.control, 1); // The lit segment carries the primary accent (Active); the unlit segment hovers // toward accent/hot when the whole control is under the pointer. const InteractionState s0State = !seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest); const InteractionState s1State = seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest); fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State); fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State); kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary); kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary); } else { // Track groove (recessed cell) + accent filled portion up to the handle + a raised // handle. Dragging THIS control brightens the fill/handle (accent/hot). const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id); const Rect track = sliderTrackRect(r.control); fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1, track.right, track.top + track.height() / 2 + 1}), Role::BgCell, InteractionState::Pressed); const Rect handle = sliderHandleRect(r.control, v); // Filled portion: track-left to the handle center. const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left); if (fillW > 0) { LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2, toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)), 1.0f, 0); } const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right, handle.bottom - 2})}; drawButton(bmp, knob, nullptr, dragging ? InteractionState::Dragging : (hov ? InteractionState::Hover : InteractionState::Rest), /*warn=*/false); } } } // --- Hover resolution (Phase L, L3) ------------------------------------------ // // Resolve the interactive element under (x, y) into hover_ and repaint only on change (an // idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline). // Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only // the frequently-touched interactive surfaces light on hover; a purely decorative region // resolves to kNone (clearing any prior hover). Windows-only. void ReaSamplerEditor::resolveHover(int x, int y) { HoverTarget h; // kNone by default RECT cr{}; GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int hgt = cr.bottom - cr.top; 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(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); const int card = (tab >= 0) ? -1 : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); if (tab >= 0) h = {HoverKind::kFilterTab, tab}; else if (card >= 0) h = {HoverKind::kCard, card}; } } 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(map_.zones.size())) { const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; const Rect panel = zonesControlPanel(content); const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); const int id = controlAtPoint(rows, x, y); if (id >= 0) h = {HoverKind::kControl, id}; } } else { // Sample view (home) const SampleBands bands = computeSampleBands(w, hgt); 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); if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1}; else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; else { const PerformanceZone zone = effectiveSampleZone(); const std::vector descs = controlDescs(zone.play); const std::vector rows = layoutControls(bands.control, descs); const int id = controlAtPoint(rows, x, y); if (id >= 0) h = {HoverKind::kControl, id}; } } } if (h != hover_) { hover_ = h; invalidate(); } } // --- Input: the drag-state machine ------------------------------------------- void ReaSamplerEditor::onMouseDown(int x, int y) { if (!processor_) return; RECT cr{}; GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; // ---- 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()) { selectedId_ = browsePendingId_; commitAndReload(); } 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(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); if (tab >= 0) { activeFilterBankId_ = (tab == 0) ? std::string() : banks_[static_cast(tab - 1)].id; rebuildVisible(); invalidate(); return; } const Rect thumb = scrollThumbRect(bl, static_cast(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(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(card)].id; if (lastBrowseClickCard_ == card && browsePendingId_ == id) { selectedId_ = id; commitAndReload(); 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) ---- if (view_ == View::kSample) { const SampleBands bands = computeSampleBands(w, h); 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 // 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(clusterPreviewButton(bands.cluster), x, y)) { const int note = effectiveRoot(); if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); previewingNote_ = note; processor_->previewNoteOn(note); invalidate(); return; } // Preview velocity knob: grab to drag (a kParamSlider drag against the velocity domain, // marked by dragParamId_ == -2 sentinel so onMouseMove routes it to setPreviewVelocity). { const Rect vs = clusterVelocitySlider(bands.cluster); if (contains(vs, x, y)) { drag_ = DragKind::kParamSlider; dragParamId_ = -2; // sentinel: the preview velocity knob (not a zone param) dragParamPanel_ = vs; const double v = valueAtPoint(vs, x); processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); invalidate(); return; } } // Channel toggle. const ChannelToggleRects chan = channelToggleRects(bands.cluster); 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; } // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. const std::vector& pcm = monoPcmFor(selectedId_); const std::int64_t frames = static_cast(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(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(hit); dragStartX_ = x; dragStartMarkers_ = m; dragSampleFrames_ = frames; dragStartMap_ = map_; return; } } // Fenced root strip: grab the root marker. const Rect rootStrip = clusterRootStrip(bands.cluster); const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); const int note = keyAtPoint(sl, x - rootStrip.left, y - 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; } // The control strip (S-VIEW-2 moved from Zone): route via the shared handler on the // effective one-zone site (materialize it on first interaction, mirror of the Zone path). { const PerformanceZone probeZone = effectiveSampleZone(); const std::vector descs = controlDescs(probeZone.play); const std::vector rows = layoutControls(bands.control, descs); if (controlAtPoint(rows, x, y) >= 0) { const int zi = ensureSampleZone(); if (zi >= 0) handleControlClick(zi, bands.control, x, y); } } return; } // ---- Zone surface (S-VIEW-8) ---- 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 full-keyboard zone for the picked capture (or the first visible sample as a // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id // already exists, select it rather than appending a duplicate (mirrors the upsert the // root-marker drag path already performs, preventing overlapping identical zones). std::string seed = !selectedId_.empty() ? selectedId_ : (!visible_.empty() ? visible_.front().id : std::string()); if (seed.empty()) return; for (int i = 0; i < static_cast(map_.zones.size()); ++i) { const PerformanceZone& z = map_.zones[static_cast(i)]; if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) { selectedZone_ = i; invalidate(); return; } } PerformanceZone z; z.sampleId = seed; z.lowNote = 0; z.highNote = 127; map_.zones.push_back(z); selectedZone_ = static_cast(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 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(map_.zones.size()), lx, ly); if (hit.zoneIndex >= 0) { selectedZone_ = hit.zoneIndex; const PerformanceZone& z = map_.zones[static_cast(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(map_.zones.size())) { const int note = keyAtPoint(sl, lx, ly); if (note >= 0) { map_.zones[static_cast(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(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 param panel: a toggle segment flips at once (commit); a slider grab starts a live drag. // Only when a zone is selected (the Zone surface has no single-capture fallback — that lives // on the Sample face now). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { handleControlClick(selectedZone_, zonesControlPanel(content), x, y); } } void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; if (id == static_cast(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); } } bool ReaSamplerEditor::handleControlClick(int zoneIndex, const Rect& panel, int x, int y) { if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return false; const ZonePlaySeconds& play = map_.zones[static_cast(zoneIndex)].play; const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); const int id = controlAtPoint(rows, x, y); if (id < 0) return false; for (const ControlRow& r : rows) { if (r.id != id) continue; if (r.kind == ControlKind::Toggle) { const int seg = toggleSegmentHitTest(r.control, x, y); if (seg >= 0) { applyZoneControl(zoneIndex, id, 0.0, seg); commitAndReload(); // a toggle is a discrete, final edit } } else { // Grab the slider: set the value at the grab x immediately, then live-drag. drag_ = DragKind::kParamSlider; dragParamId_ = id; dragParamZone_ = zoneIndex; dragParamPanel_ = panel; dragStartMap_ = map_; applyZoneControl(zoneIndex, id, valueAtPoint(r.control, x), 0); invalidate(); // live feedback; commit on WM_LBUTTONUP } break; } return true; } void ReaSamplerEditor::onMouseMove(int x, int y) { if (drag_ == DragKind::kNone) return; RECT cr{}; GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; const SampleBands bands = computeSampleBands(w, h); const int dx = x - dragStartX_; 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 Rect stripArea = clusterRootStrip(bands.cluster); 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(map_.zones.size()); ++i) { PerformanceZone& z = map_.zones[static_cast(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(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(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(zi)].play); selectedZone_ = zi; } invalidate(); // live feedback; commit on WM_LBUTTONUP return; } if (drag_ == DragKind::kWaveMarker) { // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap // it against the decoded PCM, apply the inter-marker clamps, and write the override live. const 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(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& pcm = monoPcmFor(selectedId_); if (!pcm.empty()) { newFrame = nearestZeroCrossing(pcm.data(), static_cast(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(visible_.size()), dragStartScrollOffset_, dyThumb); invalidate(); return; } if (drag_ == DragKind::kParamSlider) { // The preview-velocity knob (Sample face) uses the -2 sentinel — map x->0..1 over the // stored knob rect and write it to the processor (persisted per-instance). if (dragParamId_ == -2) { const double v = valueAtPoint(dragParamPanel_, x); if (processor_) processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); invalidate(); return; } // S12/S15/S16 + keyTrack: re-lay the panel and map x -> value against the grabbed control's // live track rect. Uses dragParamZone_ (the Sample face has no selectedZone_ coupling). const int zi = dragParamZone_; if (zi < 0 || zi >= static_cast(map_.zones.size())) return; const ZonePlaySeconds& play = map_.zones[static_cast(zi)].play; const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(dragParamPanel_, descs); for (const ControlRow& r : rows) { if (r.id == dragParamId_) { applyZoneControl(zi, dragParamId_, valueAtPoint(r.control, x), 0); break; } } 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(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(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_; drag_ = DragKind::kNone; dragParamId_ = -1; dragParamZone_ = -1; // A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a // processor-side per-instance setting already applied live — neither reloads the instrument. // Every other drag is a coherent map edit: publish the in-flight map + reload off-thread. if (kind == DragKind::kScrollThumb || (kind == DragKind::kParamSlider && paramId == -2)) { invalidate(); return; } 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) { // 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(map_.zones.size())) { PerformanceZone& z = map_.zones[static_cast(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(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(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(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(wParam)); return 0; case WM_GETDLGCODE: // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). return DLGC_WANTCHARS | DLGC_WANTARROWS; case WM_LBUTTONUP: if (self) { self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); ReleaseCapture(); } return 0; case WM_CAPTURECHANGED: // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. if (self) { // 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 preview-velocity knob (kParamSlider id==-2) 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::kParamSlider && self->dragParamId_ == -2); if (!transient) self->map_ = self->dragStartMap_; self->drag_ = DragKind::kNone; self->dragParamId_ = -1; self->dragParamZone_ = -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(wParam); const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); DragFinish(drop); if (self) self->onFilesDropped(static_cast(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