438 lines
20 KiB
C++
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
|