Files
reasampler/src/shell/instrument/editor_input_browse_zone.cpp
T

438 lines
20 KiB
C++

// 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 <algorithm>
#include <cstdint>
#include <string>
#include <vector>
#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<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
const int card = (tab >= 0)
? -1
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
else if (card >= 0) h = {HoverKind::kCard, card};
}
} else if (curvePopupOpen_) { // the 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<int>(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<std::size_t>(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<DeckGroupDesc> descs = deckGroupDescs(zone.play);
const SampleBands bands =
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
if (contains(bands.navBrowse, x, y)) {
h = {HoverKind::kNavBrowse, -1};
} else if (contains(bands.navZone, x, y)) {
h = {HoverKind::kNavZone, -1};
} else if (selectedId_.empty() && map_.zones.empty()) {
// Empty state — no interactive surfaces beyond the nav.
} else {
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, 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<int>(banks_.size()) + 1;
const int tab = filterTabHitTest(bl, tabCount, bx, by);
if (tab >= 0) {
activeFilterBankId_ = (tab == 0) ? std::string()
: banks_[static_cast<std::size_t>(tab - 1)].id;
rebuildVisible();
invalidate();
return;
}
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height > 0 &&
contains(Rect::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<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
const std::string id = visible_[static_cast<std::size_t>(card)].id;
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
loadSelection(id);
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
view_ = View::kSample;
invalidate();
} else {
browsePendingId_ = id;
lastBrowseClickCard_ = card;
invalidate();
}
return;
}
lastBrowseClickCard_ = -1;
return;
}
// 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<int>(map_.zones.size()); ++i) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
selectedZone_ = i;
invalidate();
return;
}
}
// Look up the seed's root note from the browser list (absent root defaults to 60).
int seedRoot = 60;
for (const SampleChoice& sc : samples_) {
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
}
const int lo = (std::max)(0, seedRoot - 6);
const int hi = (std::min)(127, seedRoot + 5);
PerformanceZone z;
z.sampleId = seed;
z.lowNote = lo;
z.highNote = hi;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
Rect delR = 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<int> lows, highs;
lows.reserve(map_.zones.size());
highs.reserve(map_.zones.size());
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
highs.empty() ? nullptr : highs.data(),
static_cast<int>(map_.zones.size()), lx, ly);
if (hit.zoneIndex >= 0) {
selectedZone_ = hit.zoneIndex;
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
dragStartX_ = x;
dragStartLow_ = z.lowNote;
dragStartHigh_ = z.highNote;
dragStartMap_ = map_;
switch (hit.grab) {
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
default: drag_ = DragKind::kNone; break;
}
invalidate();
return;
}
// A bare key-click inside the strip sets the selected zone's root override.
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
selectedZone_ < static_cast<int>(map_.zones.size())) {
const int note = keyAtPoint(sl, lx, ly);
if (note >= 0) {
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
commitAndReload();
}
return;
}
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
// zone is selected. entryText_ starts empty (the user types the full value).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const Rect fields = noteEntryFieldsArea(content);
for (int f = 0; f < 3; ++f) {
if (contains(noteEntryFieldRect(fields, f), x, y)) {
entryField_ = f;
entryText_.clear();
invalidate();
return;
}
}
}
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
// Zone surface has no single-capture fallback — that lives on the Sample face).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
if (contains(zonesCurveButton(content), x, y)) {
curvePopupOpen_ = true;
invalidate();
return;
}
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect deckArea = zonesDeckArea(content);
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.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<int>(ParamControl::kPitchEnvAttack) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
// Grab-anchored vertical drag: live-drag the map, commit on release.
drag_ = DragKind::kDeckKnob;
dragParamId_ = hit.id;
dragParamZone_ = selectedZone_;
dragStartMap_ = map_;
dragKnobStartValue_ = deckControlNorm(
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
dragStartX_ = x;
dragStartY_ = y;
invalidate();
}
}
}
void ReaSamplerEditor::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<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
else z.rootOverride = *note;
commitAndReload();
}
}
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 27) { // Escape cancels
entryField_ = -1;
entryText_.clear();
invalidate();
} else if (ch == 8) { // backspace
if (!entryText_.empty()) entryText_.pop_back();
invalidate();
} else if (ch >= 32 && ch < 127) {
entryText_.push_back(static_cast<char>(ch));
invalidate();
}
return;
}
// Type-to-filter search. Only when the search box has focus (a click focuses it). Backspace
// deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then
// search).
if (view_ != View::kBrowse || !searchFocused_) return;
if (ch == 8) { // backspace
if (!searchQuery_.empty()) searchQuery_.pop_back();
} else if (ch == 27) { // escape clears + defocuses
searchQuery_.clear();
searchFocused_ = false;
} else if (ch >= 32 && ch < 127) {
searchQuery_.push_back(static_cast<char>(ch));
} else {
return; // ignore other control chars
}
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
rebuildVisible();
invalidate();
}
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
// 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