S12: editor scale + S15/S16 control surfaces

Pure browser_scroll/note_entry/param_slider modules (+ CTest) for scroll,
type-to-filter search, numeric note entry, and the AHDSR/Trigger/pitch-engine/
pitch-env control panel. Editor shell draws + routes through them; params edit
the selected zone's ZonePlayParams via commitAndReload.
This commit is contained in:
2026-07-27 01:09:19 -04:00
parent ddc2ec1e75
commit 4f30124ef7
13 changed files with 1667 additions and 31 deletions
+467 -20
View File
@@ -10,11 +10,14 @@
#include <string>
#include <vector>
#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 "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#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"
@@ -175,11 +178,18 @@ void ReaSamplerEditor::refreshFromBank() {
}
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_) {
if (activeFilterBankId_.empty() || s.bankId == activeFilterBankId_)
visible_.push_back(s);
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
@@ -287,6 +297,102 @@ void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
}
}
namespace {
// The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is
// engine-free and maps only 0..1). Time sliders span [0, max] frames at a nominal rate so a
// full-throw reaches a musically generous ceiling; the exact wall-clock is DAW-verified. These
// are build-time residuals (one place to retune), not persisted.
constexpr double kEnvTimeMaxFrames = 2.0 * 44100.0; // AHDSR A/H/D/R + pitch A/D throw ceiling
constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlayParams& play) const {
std::vector<ControlDesc> out;
// Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
// Mode-relevant amplitude sliders.
if (play.playMode == PlayMode::Gate) {
out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
} else { // Trigger
out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
}
// The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
return out;
}
double ReaSamplerEditor::controlValue(int id, const ZonePlayParams& play) const {
const auto framesToNorm = [](std::int64_t f) {
return clamp01(static_cast<double>(f) / kEnvTimeMaxFrames);
};
switch (static_cast<ParamControl>(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 framesToNorm(play.adsr.attackFrames);
case ParamControl::kHold: return framesToNorm(play.adsr.holdFrames);
case ParamControl::kDecay: return framesToNorm(play.adsr.decayFrames);
case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel);
case ParamControl::kRelease: return framesToNorm(play.adsr.releaseFrames);
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 framesToNorm(play.pitchEnv.attackFrames);
case ParamControl::kPitchEnvDecay: return framesToNorm(play.pitchEnv.decayFrames);
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, ZonePlayParams& play, double value,
int segment) const {
const auto normToFrames = [](double v) {
return static_cast<std::int64_t>(clamp01(v) * kEnvTimeMaxFrames + 0.5);
};
switch (static_cast<ParamControl>(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.attackFrames = normToFrames(value); break;
case ParamControl::kHold: play.adsr.holdFrames = normToFrames(value); break;
case ParamControl::kDecay: play.adsr.decayFrames = normToFrames(value); break;
case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break;
case ParamControl::kRelease: play.adsr.releaseFrames = normToFrames(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.attackFrames = normToFrames(value); break;
case ParamControl::kPitchEnvDecay: play.pitchEnv.decayFrames = normToFrames(value); break;
case ParamControl::kPitchEnvDepth:
play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis;
break;
default: break;
}
}
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
@@ -492,6 +598,38 @@ Rect zonesStripArea(const EditorBands& bands) {
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.
Rect noteEntryFieldsArea(const EditorBands& bands) {
const Rect strip = Rect{bands.content.left + 8,
bands.content.top + 4 + 20 + 12 + kStripBandHeight,
bands.content.right - 8, 0};
const int top = strip.top + 8; // legendTop (== zonesStripArea.bottom + 8)
return Rect{bands.content.left + 8 + 128, top, bands.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 EditorBands& bands) {
constexpr int pad = 8;
const Rect strip = zonesStripArea(bands);
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
return Rect{bands.content.left + pad, panelTop, bands.content.right - pad,
bands.content.bottom - 4};
}
// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's
// header row (same y as the sample-name header, so it reads as "this capture's output mode").
// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide,
@@ -572,13 +710,31 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid
// sit below it. The search box spans the browser width.
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
LICE_FillRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width(), searchAbs.height(),
searchFocused_ ? kColTabActiveBg : kColTabBg, 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};
drawText(bmp, sbText, sb.c_str(), searchQuery_.empty() ? kRgbDim : kRgbText);
}
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
// The browser tabs + card grid, laid out by the pure module over the browser sub-area.
// capture_browser lays out from (0,0); offset the draw by browserArea's origin.
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int ox = browserArea.left;
const int oy = browserArea.top;
scrollOffset_ = clampScrollOffset(bl, static_cast<int>(visible_.size()), scrollOffset_);
// Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights.
const int tabCount = static_cast<int>(banks_.size()) + 1;
@@ -593,16 +749,24 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
drawTextCentered(bmp, t, label.c_str(), kRgbText);
}
// Cards: one per visible sample. Clip at the browser area bottom (scroll is S12).
// Cards: only the S12 visible window at the current scroll offset (a bank longer than the
// panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the
// offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges.
const int bins = thumbBins(bl);
for (int i = 0; i < static_cast<int>(visible_.size()); ++i) {
const int cardCount = static_cast<int>(visible_.size());
const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_);
for (int i = vr.first; i < vr.last; ++i) {
// The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives,
// shifted by the scroll offset (they share the cell's top, so subtract the offset).
Rect content = cardContentRect(bl, i);
if (content.top + oy >= browserArea.bottom) break; // past the visible grid
Rect thumb = cardThumbnailRect(bl, i);
Rect labelR = cardLabelRect(bl, i);
content = Rect{content.left + ox, content.top + oy, content.right + ox, content.bottom + oy};
thumb = Rect{thumb.left + ox, thumb.top + oy, thumb.right + ox, thumb.bottom + oy};
labelR = Rect{labelR.left + ox, labelR.top + oy, labelR.right + ox, labelR.bottom + oy};
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<std::size_t>(i)];
const bool sel = (s.id == selectedId_);
@@ -624,6 +788,17 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) {
drawText(bmp, badgeR, badge.c_str(), kRgbDim);
}
// S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure
// module (empty when the content fits — the shell simply draws nothing then). Offset by the
// browser origin like every other card rect.
{
const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_);
if (thumb.height() > 0) {
LICE_FillRect(bmp, thumb.left + ox, thumb.top + oy, thumb.width(), thumb.height(),
kColRootMarker, 0.8f, 0);
}
}
if (havePick) {
paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom});
} else if (visible_.empty()) {
@@ -769,18 +944,100 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) {
sel ? kColZoneBarSel : kColZoneBar, sel ? 1.0f : 0.7f, 0);
}
// A one-line legend of the selected zone below the strip.
Rect infoR{stripArea.left, stripArea.bottom + 8, stripArea.right, stripArea.bottom + 26};
// 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<int>(map_.zones.size())) {
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
std::string info = sampleLabel(samples_, z.sampleId) + " " + noteLabel(z.lowNote) +
" - " + noteLabel(z.highNote) + " root " +
(z.rootOverride ? noteLabel(*z.rootOverride) + "*" : std::string("(bank)"));
drawText(bmp, infoR, info.c_str(), kRgbText);
drawText(bmp, Rect{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, z.sampleId).c_str(), kRgbText);
// Three fields laid out left-to-right after the sample label.
const Rect fields = noteEntryFieldsArea(bands);
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);
LICE_FillRect(bmp, fr.left, fr.top, fr.width(), fr.height(),
editing ? kColTabActiveBg : kColCardBg, 1.0f, 0);
LICE_DrawRect(bmp, fr.left, fr.top, fr.width() - 1, fr.height() - 1,
kColCardBorder, 1.0f, 0);
std::string cap = std::string(names[f]) + ": " +
(editing ? (entryText_ + "_") : vals[f]);
drawText(bmp, Rect{fr.left + 4, fr.top, fr.right - 2, fr.bottom}, cap.c_str(), kRgbText);
}
} else if (map_.zones.empty()) {
drawText(bmp, infoR,
"No zones. Add Zone maps the picked capture across the keyboard.", kRgbDim);
}
// The S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
// pitch engine + AD pitch envelope). Only when a zone is selected.
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
paintControls(bmp, zonesControlPanel(computeBands(w, h)));
}
}
// 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) {
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const ZonePlayParams play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> 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", "", ""};
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<ParamControl>(r.id));
drawText(bmp, r.label, lab.label, kRgbDim);
const double v = controlValue(r.id, play);
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);
LICE_FillRect(bmp, s0.left, s0.top, s0.width(), s0.height(),
seg1 ? kColTabBg : kColTabActiveBg, 1.0f, 0);
LICE_FillRect(bmp, s1.left, s1.top, s1.width(), s1.height(),
seg1 ? kColTabActiveBg : kColTabBg, 1.0f, 0);
drawTextCentered(bmp, s0, lab.seg0, kRgbText);
drawTextCentered(bmp, s1, lab.seg1, kRgbText);
} else {
const Rect track = sliderTrackRect(r.control);
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, track.width(), 2,
kColStripKey, 1.0f, 0);
const Rect handle = sliderHandleRect(r.control, v);
LICE_FillRect(bmp, handle.left, handle.top + 2, handle.width(), handle.height() - 4,
kColRootMarker, 1.0f, 0);
}
}
}
// --- Input: the drag-state machine -------------------------------------------
@@ -801,7 +1058,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
const bool havePick = !selectedId_.empty();
const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const Rect browserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop};
// S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below.
const Rect searchBox = searchBoxRect(fullBrowserArea.width());
const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top,
fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom};
if (contains(searchAbs, x, y)) {
searchFocused_ = true;
invalidate();
return;
}
searchFocused_ = false; // any other browser click defocuses the search box
const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop};
const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height());
const int bx = x - browserArea.left;
const int by = y - browserArea.top;
@@ -816,8 +1086,20 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
invalidate();
return;
}
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture).
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by);
// S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the
// grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then.
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
if (thumb.height() > 0 &&
contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top,
thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) {
drag_ = DragKind::kScrollThumb;
dragStartY_ = y;
dragStartScrollOffset_ = scrollOffset_;
return;
}
// Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The
// hit-test adds the scroll offset back so a scrolled card maps to the right index.
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
if (card >= 0) {
selectedId_ = visible_[static_cast<std::size_t>(card)].id;
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
@@ -958,6 +1240,54 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
map_.zones[static_cast<std::size_t>(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<int>(map_.zones.size())) {
const Rect fields = noteEntryFieldsArea(bands);
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 Zones view cancels an in-progress entry
// The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab
// starts a live drag (commit on release). Only when a zone is selected.
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const Rect panel = zonesControlPanel(bands);
const std::vector<ControlDesc> descs = controlDescs(z.play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) {
// Find the row to know its kind + control rect.
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) {
applyControl(id, z.play, 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;
dragParamPanel_ = panel;
dragStartMap_ = map_;
applyControl(id, z.play, valueAtPoint(r.control, x), 0);
invalidate(); // live feedback; commit on WM_LBUTTONUP
}
break;
}
}
}
}
@@ -1044,6 +1374,40 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
return;
}
if (drag_ == DragKind::kScrollThumb) {
// S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
// window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser
// when a capture is picked (the setup band takes the bottom).
const int dyThumb = y - dragStartY_;
const bool havePick = !selectedId_.empty();
const int setupTop = havePick
? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight)
: bands.content.bottom;
const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top);
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
dragStartScrollOffset_, dyThumb);
invalidate();
return;
}
if (drag_ == DragKind::kParamSlider) {
// S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live
// track rect (the panel geometry is stable during the drag; re-laying keeps the value
// mapping exact even if a mode toggle changed the row set — it did not, mid-drag).
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
const std::vector<ControlDesc> descs = controlDescs(z.play);
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
for (const ControlRow& r : rows) {
if (r.id == dragParamId_) {
applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0);
break;
}
}
invalidate();
return;
}
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(bands);
@@ -1068,11 +1432,79 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) {
if (drag_ == DragKind::kNone) return;
const DragKind kind = drag_;
drag_ = DragKind::kNone;
// One coherent edit lands on release: publish the in-flight map + reload off-thread.
// A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other
// drag is a coherent map edit: publish the in-flight map + reload off-thread on release.
if (kind == DragKind::kScrollThumb) {
invalidate();
return;
}
commitAndReload();
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls
// roughly one card row; the offset is clamped at paint (the layout/panel height is known
// there). A positive delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowser) 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 (Zones view): 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::kZones && 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;
}
// 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::kBrowser || !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();
}
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
LPARAM lParam) {
auto* self =
@@ -1088,12 +1520,24 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
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) self->onMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam));
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<unsigned int>(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));
@@ -1106,7 +1550,10 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
// 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 && self->drag_ != DragKind::kNone) {
self->map_ = self->dragStartMap_;
// A scrollbar drag is transient (no map mutation + dragStartMap_ was not
// snapshotted for it) — reset the drag state only, never touch map_. Every
// map-editing drag rolls its live mutation back to the pre-grab snapshot.
if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->invalidate();
}