// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface // input + the hover resolver: hover resolution across all three faces, the Browse picker's // click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the // Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck // + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes, // and the degraded drop affordance. Windows-only. #include "shell/instrument/reasampler_editor.h" #ifdef _WIN32 #include #include #include #include #include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry #include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover) #include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize #include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry) #include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover) #include "shell/instrument/reasampler_processor.h" namespace reasampler::vst { using namespace reasampler::ui; using namespace reasampler::instrument::ui; using namespace reasampler::instrument::map; // Resolve the interactive element under (x, y) into hover_ and repaint only on change (an // idle move is free). Mirrors onMouseDown's hit-test order, but read-only. 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.x; const int by = y - bm.content.y; 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 (curvePopupOpen_) { // the curve popup — modal over Sample and Zone const CurvePopupLayout pl = computeCurvePopup(w, hgt); if (contains(pl.close, x, y)) { h = {HoverKind::kPopupClose, -1}; } else if (contains(pl.curveBox, x, y)) { // A curve node under the pointer lights accent-hot. const int idx = popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y); if (idx >= 0) h = {HoverKind::kCurveNode, idx}; } } else if (view_ == View::kZone) { const Rect back = zoneBackRect(w, hgt); const Rect content = zoneContentArea(w, hgt); Rect addR = zoneAddRect(content); Rect delR = zoneDeleteRect(addR); 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())) { // The per-zone knob deck + the mini curve-preview button (the Sample deck's hover // grammar — knobs light + swap label->value). if (contains(zonesCurveButton(content), x, y)) { h = {HoverKind::kCurveButton, -1}; } else { const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; const Rect deckArea = zonesDeckArea(content); const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, deckArea.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } } } else { // Sample view (home) const PerformanceZone zone = effectiveSampleZone(); const std::vector descs = deckGroupDescs(zone.play); const SampleBands bands = computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad)); if (contains(bands.navBrowse, x, y)) { h = {HoverKind::kNavBrowse, -1}; } else if (contains(bands.navZone, x, y)) { h = {HoverKind::kNavZone, -1}; } else if (selectedId_.empty() && map_.zones.empty()) { // Empty state — no interactive surfaces beyond the nav. } else { const ChannelToggleRects chan = channelToggleRects(bands.cluster); const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize); if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1}; else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1}; else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1}; else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; else if (contains(bands.deck, x, y)) { // A deck knob/toggle under the pointer: knobs light + swap label->value. const DeckLayout dl = layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width); const DeckHit dh = hitTestDeck(dl, x, y); if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id}; } } } if (h != hover_) { hover_ = h; invalidate(); } } // The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the // dispatch). void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) { const BrowseModal bm = computeBrowseModal(w, h); if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { // Cancel/Back: discard the pending pick, return to Sample unchanged. browsePendingId_.clear(); searchFocused_ = false; view_ = View::kSample; invalidate(); return; } if (contains(bm.confirm, x, y)) { // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. if (!browsePendingId_.empty()) { loadSelection(browsePendingId_); } browsePendingId_.clear(); searchFocused_ = false; view_ = View::kSample; invalidate(); return; } if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } searchFocused_ = false; const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height); const int bx = x - bm.content.x; const int by = y - bm.content.y; 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::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y, thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), 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) { loadSelection(id); browsePendingId_.clear(); lastBrowseClickCard_ = -1; searchFocused_ = false; view_ = View::kSample; invalidate(); } else { browsePendingId_ = id; lastBrowseClickCard_ = card; invalidate(); } return; } lastBrowseClickCard_ = -1; return; } // The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the // Zone surface too). void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) { if (handlePopupMouseDown(w, h, x, y)) return; const Rect back = zoneBackRect(w, h); if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } const Rect content = zoneContentArea(w, h); Rect addR = zoneAddRect(content); if (contains(addR, x, y)) { // Add a narrow default zone for the picked capture (or the first visible sample as a // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id // already exists, select it rather than appending a duplicate (mirrors the upsert the // root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one // octave centred on the bank root, clamped to [0,127]) so the new zone is immediately // "authored" (narrow) and survives reconcileSingleCaptureZones without being treated // as a Sample-face full-range zone. std::string seed = !selectedId_.empty() ? selectedId_ : (!visible_.empty() ? visible_.front().id : std::string()); if (seed.empty()) return; for (int i = 0; i < static_cast(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; } } // Look up the seed's root note from the browser list (absent root defaults to 60). int seedRoot = 60; for (const SampleChoice& sc : samples_) { if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; } } const int lo = (std::max)(0, seedRoot - 6); const int hi = (std::min)(127, seedRoot + 5); PerformanceZone z; z.sampleId = seed; z.lowNote = lo; z.highNote = hi; map_.zones.push_back(z); selectedZone_ = static_cast(map_.zones.size()) - 1; commitAndReload(); return; } Rect delR = zoneDeleteRect(addR); 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.x; const int ly = y - stripArea.y; 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; } // 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 per-zone param surface: the knob deck + the mini curve-preview button — the same // grammar and hit-test machinery as the Sample face. Only when a zone is selected (the // Zone surface has no single-capture fallback — that lives on the Sample face). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { if (contains(zonesCurveButton(content), x, y)) { curvePopupOpen_ = true; invalidate(); return; } const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; const Rect deckArea = zonesDeckArea(content); const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y, deckArea.width); const DeckHit hit = hitTestDeck(dl, x, y); if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) { // Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete, // final edit committed at once (the deck precedent). No per-instance ids reach // here — VOICE/MASTER are not in the zone group set. applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment); commitAndReload(); return; } if (hit.kind == DeckHitKind::Knob) { // PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the // Sample deck's guard, mirrored. const bool pitchEnvKnob = hit.id == static_cast(ParamControl::kPitchEnvAttack) || hit.id == static_cast(ParamControl::kPitchEnvDecay) || hit.id == static_cast(ParamControl::kPitchEnvDepth); if (pitchEnvKnob && !play.pitchEnv.enabled) return; // Grab-anchored vertical drag: live-drag the map, commit on release. drag_ = DragKind::kDeckKnob; dragParamId_ = hit.id; dragParamZone_ = selectedZone_; dragStartMap_ = map_; dragKnobStartValue_ = deckControlNorm( hit.id, map_.zones[static_cast(selectedZone_)]); dragStartX_ = x; dragStartY_ = y; invalidate(); } } } 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) { // The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face // or the Zone surface; opening it clears any note-entry focus, and the Browse search // cannot hold focus under it). if (curvePopupOpen_ && ch == 27) { curvePopupOpen_ = false; invalidate(); return; } // 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; } // 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) { // The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor // drop -> extension) is not shipped, 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 } } // namespace reasampler::vst #endif // _WIN32