// editor_input_deck.cpp — the DECKS band's input: toggles (committed at once, a discrete // final edit), knob grabs (grab-anchored vertical drag, committed on release), the live // knob-drag resolution, and the band's hover. Windows-only. #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 #include "core/instrument/ui/deck_values.h" // snapDeckParamNorm (Shift's whole-unit table) #include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck #include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag) #include "shell/instrument/editor_internal.h" #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { using namespace reasampler::ui; using namespace reasampler::instrument::ui; bool ReaSamplerEditor::deckKnobDisabled(int id) const { // Thin int-id wrapper over the pure, CTest-covered predicate — see deckKnobInert // (deck_groups.h) for which cells go inert and why. return deckKnobInert(static_cast(id), deckEnableState()); } bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) { const Rect& band = fl.bands.decks; if (!contains(band, x, y)) return false; const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckHit hit = hitTestDeck(dl, x, y); // The deck IS the overlay's click target: anywhere inside an envelope group — panel // background, knob or button — focuses that envelope, and any other group clears it. Set // BEFORE the kind switch and it consumes nothing, so every grab and commit below still // runs. A drag in flight owns the surface, so it refuses the change — always true at this // call site today (mouse capture makes a real WM_LBUTTONDOWN-while-dragging unreachable; // WM_CAPTURECHANGED and WM_LBUTTONUP both reset drag_ before another down can land), but // left explicit rather than assumed so a future capture-handling change fails loud, not // by silently letting a drag's own surface steal its own focus mid-gesture. if (drag_ == DragKind::kNone) { const OverlayEnv focus = overlayEnvForGroup(hit.group); if (focus != overlayEnv_) { overlayEnv_ = focus; invalidate(); // view state only: no parameter write, no reload } } if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { switch (static_cast(hit.id)) { case ParamControl::kVoiceMode: { // Processor-side per-instance param: live setter (engine rebuild via the // drain-slot swap — tails survive), local snapshot in step. const VoiceMode m = (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly; if (m != voiceMode_) { voiceMode_ = m; processor_->setVoiceMode(m); } invalidate(); break; } case ParamControl::kMonoTrigger: { if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly const MonoTrigger t = (hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; if (t != monoTrigger_) { monoTrigger_ = t; processor_->setMonoTrigger(t); } invalidate(); break; } case ParamControl::kFilterLaw: // Inert while the filter is off, matching its Disabled paint. if (!params_.play.filter.enabled) break; applyParamControl(hit.id, 0.0, hit.segment); commitAndReload(); break; case ParamControl::kLimiterEnable: { // One button, so its next state is the opposite of the current one — which is // also why the old "did it actually change" guard is gone: it always does. const bool on = !params_.limiterEnabled; params_.limiterEnabled = on; // Commits the audible state and the persisted state together, here, because // this is a control the user A/Bs. The funnel only ARMS the host's latency // restart — the sync tick delivers it — so nothing on this path calls into // the host from inside a mouse handler. processor_->setLimiterEnabled(on); invalidate(); break; } default: { // Parameter-set toggles (play mode / pitch engine / pitch-env + filter enable, // and the three env-mode selectors). A single button carries no segment, so // its next state is derived from the parameter set rather than read off the // click — deck_values owns that derivation. const int segment = hit.segment >= 0 ? hit.segment : nextToggleSegment(static_cast(hit.id), params_.play); applyParamControl(hit.id, 0.0, segment); commitAndReload(); break; } } return true; } if (hit.kind == DeckHitKind::Column) { // The meter's ONLY gesture: clear the latched clip cap. Both latches go — the audio // thread's is what the next tick would otherwise re-latch the UI's from. masterMeter_ = clearMasterMeterClip(masterMeter_); processor_->clearMasterBusClip(); invalidate(); return true; } if (hit.kind == DeckHitKind::Knob) { // Knobs of a disabled group are drawn but inert. if (deckKnobDisabled(hit.id)) return true; // A VELOCITY cell summons the popup editor instead of starting a knob drag. const CurveTarget curveCell = curveTargetFor(hit.id); if (curveCell != CurveTarget::kNone) { curvePopup_ = curveCell; invalidate(); return true; } // A grab on the inner disc drags the CURVE control instead, but only where the stage // is sloped; on a Hold or Sustain cell the inner region is just more of the knob. const ParamControl curve = curveParamFor(static_cast(hit.id)); const bool inner = hit.inner && curve != ParamControl::kCount; drag_ = DragKind::kDeckKnob; dragParamId_ = inner ? static_cast(curve) : hit.id; dragInnerCellId_ = inner ? hit.id : -1; dragKnobStartValue_ = deckControlNorm(dragParamId_); dragMods_ = dragModifiers(); // Processor-side knobs (voice count / master gain) are transient live writes with no // parameter-set mutation, so they need no rollback snapshot. dragStartParams_ = params_; dragStartX_ = x; dragStartY_ = y; // Opens the host's edit bracket for the whole gesture, so a host in touch or latch mode // records one continuous edit rather than a burst of one-point ones. Closed on release // and on capture-lost; a control with no parameter row is a no-op inside the processor. if (processor_) { processor_->beginParamGesture(static_cast(dragParamId_)); } invalidate(); } // The deck band swallows its own clicks either way — no fall-through to the waveform. return true; } bool ReaSamplerEditor::doubleClickDeck(const FaceLayout& fl, int x, int y) { const Rect& band = fl.bands.decks; if (!contains(band, x, y)) return false; const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); // Resolved against the drawn CIRCLES (hitTestKnobFace), not the cell a drag grabs anywhere // in: the two rings are concentric, so only a radial resolve can tell "reset the exponent" // from "reset the value". const DeckFaceHit hit = hitTestKnobFace(dl, x, y); if (hit.id < 0) return false; if (deckKnobDisabled(hit.id)) return true; // drawn-but-dead: swallow, change nothing // A VELOCITY cell is a popup opener with no scalar value to reset. if (curveTargetFor(hit.id) != CurveTarget::kNone) return true; const ParamControl curve = curveParamFor(static_cast(hit.id)); const bool inner = hit.inner && curve != ParamControl::kCount; const int target = inner ? static_cast(curve) : hit.id; // The processor-side knobs own their own defaults — they never reach the parameter set. if (target == static_cast(ParamControl::kVoiceCount)) { voiceCount_ = kDefaultVoiceCount; processor_->setVoiceCount(voiceCount_); invalidate(); return true; } if (target == static_cast(ParamControl::kMasterGain)) { processor_->setMasterGainLinear(1.0); invalidate(); return true; } resetParamControl(target); // Same commit tiering a drag of this control takes, so a reset reaches the sounding note // exactly as a drag to the same value would. if (dragCommitsLive(DragKind::kDeckKnob, target)) commitLive(); else commitAndReload(); invalidate(); return true; } void ReaSamplerEditor::dragDeck(int x, int y) { // Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the // value at grab (up = increase), so the value tracks relative motion and never jumps on // grab. Live feedback; parameter-set commits land on WM_LBUTTONUP. (void)x; const DragModifiers mods = dragModifiers(); if (mods != dragMods_) { // Re-anchor (see dragMods_). Reading the anchor back off the control also means a Shift // RELEASE re-anchors from the SNAPPED value, so the knob does not spring back. dragKnobStartValue_ = deckControlNorm(dragParamId_); dragStartY_ = y; dragMods_ = mods; } double norm = knobDragValue(dragKnobStartValue_, y - dragStartY_, mods); // The preview-velocity sentinel (-2) and the discrete controls have no whole unit to snap to. if (mods.shift && dragParamId_ >= 0) { norm = snapDeckParamNorm(static_cast(dragParamId_), norm); } applyDeckKnob(dragParamId_, norm); // A live control is delivered on every move, not only on release — that is the whole // point: the note already sounding tracks the hand on the knob. The processor-side knobs // are skipped because applyDeckKnob already wrote them straight through; commitLive would // only re-push an unchanged parameter set. The release and capture-lost paths ask this same // question; the reset path never reaches the commit for them at all. if (!deckKnobIsProcessorSide(dragParamId_) && dragCommitsLive(DragKind::kDeckKnob, dragParamId_)) { commitLive(); } invalidate(); } HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x, int y) const { const Rect& band = fl.bands.decks; if (!contains(band, x, y)) return {}; const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind == DeckHitKind::None) return {}; // The meter reports its own state continuously; a hover on it would only mean "the clip // cap is clearable", which the cap's presence already says. if (dh.kind == DeckHitKind::Column) return {}; if (dh.kind == DeckHitKind::Knob && dh.inner && curveParamFor(static_cast(dh.id)) != ParamControl::kCount) { // Indexed by the OUTER cell id so the paint side can find the cell it belongs to. return {HoverKind::kInnerDial, dh.id}; } return {HoverKind::kControl, dh.id}; } } // namespace reasampler::vst #endif // _WIN32